whyno v0.4.0 — metadata permission operations
Version: 0.4
context
v0.3.0 shipped test infrastructure: StateBuilder extensions for synthetic MAC state, a Docker build matrix, and Proxmox VM environments for live kernel integration tests. The check pipeline now has confidence in its 7-layer model for rwx-gated operations.
However, whyno currently only models operations that gate on rwx mode bits or parent directory permissions. Metadata-changing operations — chmod, chown, chgrp, setxattr — follow fundamentally different kernel permission logic rooted in file ownership and Linux capabilities, not rwx bits. These are among the most frequently debugged permission failures in production (chmod: changing permissions of '/var/log/app': Operation not permitted), yet whyno cannot diagnose them.
what the kernel actually checks
The kernel's setattr_prepare() in fs/attr.c implements the authoritative rules:
| Syscall | Kernel gate | Capability override | Side effects |
|---|---|---|---|
chmod |
inode->i_uid == current_fsuid() |
CAP_FOWNER bypasses ownership check |
Kernel strips setgid bit if caller lacks CAP_FSETID and is not in file's group |
chown (change UID) |
Always denied for non-privileged | CAP_CHOWN required |
Kernel strips setuid/setgid bits on success |
chgrp (change GID) |
inode->i_uid == current_fsuid() AND caller is member of new GID |
CAP_CHOWN bypasses both checks |
Kernel strips setuid/setgid bits on success |
utimes / futimens |
Owner OR CAP_FOWNER, or w bit if setting to current time |
CAP_FOWNER bypasses ownership check; CAP_DAC_OVERRIDE bypasses w check |
None |
None of these checks consult rwx mode bits (except utimes with UTIME_NOW). The existing required_bit() → check_bit() → check_dac() pipeline cannot model them.
xattr namespace rules
Extended attribute operations follow per-namespace permission models that are orthogonal to DAC:
| Namespace | Set requirement | Get requirement | Remove requirement |
|---|---|---|---|
user.* |
File owner (DAC w on regular files) |
DAC r |
File owner |
trusted.* |
CAP_SYS_ADMIN |
CAP_SYS_ADMIN |
CAP_SYS_ADMIN |
security.* |
CAP_SYS_ADMIN |
No restriction (all processes can read) | CAP_SYS_ADMIN |
system.posix_acl_* |
File owner OR CAP_FOWNER |
No restriction | File owner OR CAP_FOWNER |
MAC layer interactions
SELinux maps metadata operations to distinct object class permissions:
chmod/chown/utimes→file:setattr- Changing SELinux context →
file:relabelfrom(old context) +file:relabelto(new context) - Xattr operations on
security.*→file:setattr(not the generic xattr permissions)
AppArmor mediates metadata operations through capability rules, not file permission bits:
chmodby non-owner → requirescapability fownerin the profilechown→ requirescapability chownin the profilesetxattrontrusted.*/security.*→ requirescapability sys_admin- AppArmor does not have file-rule-level mediation for
chmod/chown— it delegates to the kernel DAC + capability model and only interposes at the capability grant level
what exists in the codebase today
| Component | Current state | Gap |
|---|---|---|
Operation enum |
6 variants: Read, Write, Execute, Delete, Create, Stat |
No metadata-change variants |
required_bit() in dac.rs |
Maps every op to r, w, or x |
Cannot express "check ownership, not mode bits" |
capability_modify() in caps_modify.rs |
Only handles CAP_DAC_OVERRIDE as a post-DAC modifier |
Does not model CAP_FOWNER, CAP_CHOWN, CAP_FSETID, CAP_SYS_ADMIN |
caps.rs constants |
Defines CAP_CHOWN, CAP_FOWNER, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_LINUX_IMMUTABLE |
Constants exist but are unused outside CAP_DAC_OVERRIDE. No CAP_FSETID or CAP_SYS_ADMIN |
operation_to_selinux_perm() |
Maps to file:{read,write,execute} |
No file:setattr, file:relabelfrom, file:relabelto |
check_apparmor() |
Checks profile mode (enforce/complain/unconfined) | No capability-rule mediation for metadata ops |
FixAction enum |
Has Chmod and Chown variants already defined |
Chown is structurally present but never generated — no generator produces it. No SetXattr variant |
generate_fixes() in fix/mod.rs |
Collects from mount, fsflags, traversal, DAC, ACL layers | No metadata-operation fix collection. No capability-grant fixes |
decision
v0.4.0 introduces metadata permission operations as a new operation class with its own check path that bypasses required_bit() / check_bit() and instead evaluates ownership + capability rules directly.
1. extend Operation enum
Add four new variants to the #[non_exhaustive] enum:
pub enum Operation {
// ... existing variants ...
/// Change file mode bits (chmod). Requires ownership or CAP_FOWNER.
Chmod,
/// Change file owner UID (chown). Requires CAP_CHOWN.
ChownUid,
/// Change file group GID (chgrp). Requires ownership + group membership, or CAP_CHOWN.
ChownGid,
/// Set extended attribute. Permission depends on namespace.
SetXattr {
/// The xattr namespace: "user", "trusted", "security", "system.posix_acl".
namespace: XattrNamespace,
},
}New enum for xattr namespaces:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum XattrNamespace {
User,
Trusted,
Security,
SystemPosixAcl,
}is_metadata() implementation must be an exhaustive match on Operation with no wildcard arm:
pub fn is_metadata(&self) -> bool {
matches!(self, Operation::Chmod | Operation::ChownUid | Operation::ChownGid | Operation::SetXattr { .. })
}A wildcard fallback (_ => false) would silently misroute any future variant added to the enum — new metadata ops would fall through to the DAC rwx path without a compiler error. Exhaustive matching forces every new variant to be classified at the call site. #[non_exhaustive] is retained on Operation for external consumers; internal code must never use _ in is_metadata(). Enable clippy::wildcard_enum_match_arm project-wide to enforce this in CI.
Rationale: ChownUid and ChownGid are split because the kernel applies fundamentally different rules — UID change always requires CAP_CHOWN, while GID change allows owner+group-member without any capability. SetXattr(XattrNamespace) is the sole exception to unit variants: namespace is operation-class-defining (different namespaces have entirely different permission models), not a runtime parameter.
User-intent data (new_gid, new_mode, new_uid) is caller-supplied context, not OS-gathered state. It belongs in a dedicated MetadataParams struct passed alongside SystemState, not encoded into the variant.
1a. MetadataParams — user-intent data
/// Caller-supplied intent for metadata-change operations.
///
/// Separate from `SystemState` (OS-gathered state). Carries the values
/// the caller wants to apply, not current file state.
pub struct MetadataParams {
/// Target mode bits for `Chmod`. Required when operation is `Chmod`.
pub new_mode: Option<u32>,
/// Target UID for `ChownUid`. Required when operation is `ChownUid`.
pub new_uid: Option<u32>,
/// Target GID for `ChownGid`. Required when operation is `ChownGid`.
pub new_gid: Option<u32>,
}When a required field is None for the active operation, the check returns Degraded with a message indicating the missing parameter — not a hard Fail. The gather layer populates MetadataParams from CLI flags and passes it through to run_checks().
2. new checks/metadata.rs layer
The metadata check is not a DAC check — it does not consult rwx mode bits. It gets its own module and its own CoreLayer::Metadata variant.
pub enum CoreLayer {
Mount,
FsFlags,
Traversal,
Dac,
Acl,
Metadata, // new
}Pipeline position: Metadata runs after ACL and before MAC layers. Metadata ops still require traversal (ancestors need +x) and are blocked by mount flags (ro blocks chmod), so Mount → FsFlags → Traversal run first. DAC and ACL are skipped for metadata ops. Metadata is the terminal DAC-equivalent layer for these operations.
check_metadata() dispatch logic:
pub fn check_metadata(state: &SystemState, params: &MetadataParams) -> LayerResult {
match state.operation {
Operation::Chmod => check_chmod(state, params),
Operation::ChownUid => check_chown_uid(state, params),
Operation::ChownGid => check_chown_gid(state, params),
Operation::SetXattr(namespace) => check_setxattr(state, namespace),
_ => LayerResult::Pass {
detail: "not a metadata operation".into(),
warnings: vec![],
},
}
}check_chmod rules (mirrors setattr_prepare for ATTR_MODE):
- If
target.uid == subject.uid→ Pass - Else if
has_cap(subject.capabilities, CAP_FOWNER)→ Pass with warning about setgid strip if applicable - Else → Fail:
"subject (uid=X) is not the file owner (uid=Y) and lacks CAP_FOWNER"
check_chown_uid rules (mirrors setattr_prepare for ATTR_UID):
- If
has_cap(subject.capabilities, CAP_CHOWN)→ Pass - Else → Fail:
"changing file UID requires CAP_CHOWN"
check_chown_gid rules (mirrors setattr_prepare for ATTR_GID):
- If
params.new_gidisNone→ Degraded:"new_gid required for ChownGid check — pass --new-gid" - If
has_cap(subject.capabilities, CAP_CHOWN)→ Pass - Else if
target.uid == subject.uidANDsubject.in_group(params.new_gid)→ Pass - Else if
target.uid != subject.uid→ Fail:"subject (uid=X) is not the file owner (uid=Y) and lacks CAP_CHOWN" - Else → Fail:
"subject is file owner but is not a member of group (gid=Z) and lacks CAP_CHOWN"
check_setxattr rules (per-namespace dispatch):
User→ owner check ORCAP_FOWNERTrusted→CAP_SYS_ADMINSecurity→CAP_SYS_ADMINSystemPosixAcl→ owner check ORCAP_FOWNER
3. modify DAC and ACL layers to skip metadata ops
check_dac() and check_acl() each gain an early return:
if state.operation.is_metadata() {
return LayerResult::Pass {
detail: "metadata ops bypass DAC mode-bit checks".into(),
warnings: vec![],
};
}The rwx check path must not run for operations that don't consult mode bits.
4. new capability constants
Add to caps.rs:
/// Don't clear set-user-ID and set-group-ID mode bits when a file
/// is modified. Don't set the set-group-ID bit on a newly created
/// file if the GID doesn't match the caller's group membership. Bit 4.
pub const CAP_FSETID: u64 = 1 << 4;
/// Broad system administration capability. Required for trusted.*
/// and security.* xattr namespaces. Bit 21.
pub const CAP_SYS_ADMIN: u64 = 1 << 21;5. capability_modify() — no change
Decision: Split into capability_modify_dac() and leave metadata caps in check_metadata().
capability_modify() is a DAC post-processor — it takes a failing DAC result and upgrades it to Pass if CAP_DAC_OVERRIDE is set. Metadata operations have their own capability logic that is integral to the check itself, not a post-processor. Forcing all capability logic through a single modifier function would create a god-function.
- Rename
capability_modify()→capability_modify_dac()(no behavior change) - Metadata capability checks live inside
check_metadata()directly run_dac_with_caps()continues to callcapability_modify_dac()only forrwxoperations
6. update SELinux mapping
Extend operation_to_selinux_perm() (promote from #[cfg(test)] to pub(crate)):
pub(crate) fn operation_to_selinux_perm(op: &Operation) -> (&'static str, &'static str) {
match op {
Operation::Read | Operation::Stat => ("file", "read"),
Operation::Write | Operation::Create | Operation::Delete => ("file", "write"),
Operation::Execute => ("file", "execute"),
Operation::Chmod | Operation::ChownUid | Operation::ChownGid => ("file", "setattr"),
// TODO(v0.5): differentiate security.selinux key → relabelfrom/relabelto
Operation::SetXattr { .. } => ("file", "setattr"),
}
}Consolidate to a single canonical copy. Two copies currently exist — one in gather/mac.rs (live, used for AVC queries) and one in checks/selinux.rs (test-only). Resolution:
- Remove the
#[cfg(test)]gate fromchecks/selinux.rs::operation_to_selinux_perm()and promote it topub(crate) - Extend it with the new
setattrmappings - Delete the duplicate in
gather/mac.rs— the gather layer imports and calls the canonical copy fromwhyno-coreinstead
7. update AppArmor check
AppArmor mediates metadata ops through capability rules in the profile, not file access rules. For v0.4.0, check_apparmor() gains a metadata-aware detail message:
ProfileMode::Enforce => {
if state.operation.is_metadata() {
LayerResult::Degraded {
reason: format!(
"AppArmor: profile '{}' in enforce mode — metadata ops require capability rules (chown/fowner/sys_admin) which cannot be queried without libapparmor",
profile_name
),
}
} else {
// existing behavior
}
}Full AppArmor capability-rule query (via aa_query_label) is deferred to a future version that links libapparmor.
8. fix engine — metadata fix generators
New generators::metadata_fixes() function:
For Chmod failures:
FixAction::Chown— transfer ownership to subject (impact 4)FixAction::GrantCap { cap: "cap_fowner" }— grantCAP_FOWNERto subject's executable (impact 5)
For ChownUid / ChownGid failures:
FixAction::GrantCap { cap: "cap_chown" }(impact 5)
For SetXattr failures (namespace-dependent):
Usernamespace:FixAction::Chownto transfer ownership (impact 4)Trusted/Security:FixAction::GrantCap { cap: "cap_sys_admin" }(impact 6 — high-impact warning)SystemPosixAcl:FixAction::ChownorFixAction::GrantCap { cap: "cap_fowner" }(impact 4–5)
New FixAction variant:
/// Grant a Linux capability to a process via file capabilities.
GrantCap {
/// Target executable path. None when process binary is unknown.
path: Option<PathBuf>,
/// Capability name (e.g., "cap_fowner", "cap_chown").
capability: String,
},Command rendering: setcap cap_fowner+ep /usr/bin/myapp. When path is None, renders as a descriptive recommendation — the fix is still included in cascade simulation (capability bit is set on the cloned state) so downstream fix pruning works correctly. Full resolution of missing executable path (e.g. resolving a systemd service → ExecStart binary automatically) is deferred to v0.5.0.
9. cascade simulation update
cascade::apply::apply_fix() gains two new arms:
GrantCap: Mutates state.subject.capabilities to set the granted capability bit. Re-runs checks to see if metadata layer now passes.
Chown: Mutates target.stat.uid and/or target.stat.gid in the walk. Clears S_ISUID. Clears S_ISGID if S_IXGRP is set. Mirrors the kernel's ATTR_KILL_SUID/ATTR_KILL_SGID side effect in notify_change().
10. pipeline integration
run_checks() and generate_fixes() both receive &MetadataParams as a second argument (breaking change). Pass MetadataParams::default() for non-metadata operations.
11. gathering layer
whyno-gather constructs MetadataParams from CLI flags:
--new-mode <octal>→MetadataParams::new_mode--new-uid <uid>→MetadataParams::new_uid--new-gid <gid>→MetadataParams::new_gid--xattr-key <key>→XattrNamespaceparsed from key prefix →Operation::SetXattr { namespace }
MetadataParams fields are all Option<u32>. Missing required fields degrade gracefully in the check layer rather than failing at gather time.
consequences
positive
- Diagnoses the most common sysadmin permission failure —
chmod/chownEPERM is ubiquitous in container deployments, NFS mounts, and rootless setups - Activates dormant code —
CAP_FOWNER,CAP_CHOWNconstants andFixAction::Chownalready exist but are unused - Clean separation — metadata ops get their own layer rather than being shoehorned into the DAC mode-bit path
- Capability model grows naturally —
CAP_FSETIDandCAP_SYS_ADMINcomplete the set of file-permission-relevant capabilities
negative
CoreLayerenum grows to 6 variants — theEnumMapapproach scales, but the pipeline and fix engine become more complexOperationenum gains a variant with data —SetXattr { namespace }breaks the current pattern of simple unit variants; allmatcharms across the codebase need updating- Cascade simulation complexity —
GrantCapneeds capability bitmask math;Chownneeds to update stat entries and trigger setuid/setgid bit stripping - AppArmor remains degraded — without
libapparmor, enforce-mode capability mediation cannot be queried
deferred
- SELinux
relabelfrom/relabelto— only relevant when settingsecurity.selinuxxattr utimes/futimens— hybrid ownership-or-write-bit check, lower priority- AppArmor capability rule query — requires linking
libapparmorand callingaa_query_label() - Namespace-aware capability checks — in user namespaces,
CAP_CHOWNandCAP_FOWNERare scoped to the namespace
implementation order
caps.rs— AddCAP_FSETIDandCAP_SYS_ADMINconstantsoperation.rs— AddChmod,ChownUid,ChownGid,SetXattrvariants +XattrNamespaceenum +is_metadata()checks/metadata.rs— New module withcheck_metadata()and sub-checkschecks/dac.rs— Add early return for metadata opschecks/acl.rs— Add early return for metadata opschecks/mod.rs— AddCoreLayer::Metadata, wire into pipelinechecks/selinux.rs— Promoteoperation_to_selinux_perm()topub(crate), extend, delete duplicate ingather/mac.rschecks/apparmor.rs— Add metadata-aware degraded message for enforce modefix/mod.rs— AddFixAction::GrantCap,collect_metadata_fixes()fix/generators.rs— Newmetadata_fixes()generatorfix/cascade/apply.rs— ImplementGrantCapandChownapply armswhyno-gather— Add--new-uid,--new-gid,--xattr-keyflagswhyno-cli— Wire new--opvalues- Tests — Unit tests per module, integration tests via
StateBuilderextensions
test strategy
unit tests (synthetic state via StateBuilder)
chmodas owner → Passchmodas non-owner withoutCAP_FOWNER→ Failchmodas non-owner withCAP_FOWNER→ Pass + setgid strip warningchown-uidwithoutCAP_CHOWN→ Failchown-uidwithCAP_CHOWN→ Passchown-gidas owner in target group → Passchown-gidas owner not in target group → Failchown-gidwithCAP_CHOWN→ Pass regardless of group membershipsetxattr user.*as owner → Passsetxattr user.*as non-owner → Failsetxattr trusted.*withoutCAP_SYS_ADMIN→ Failsetxattr security.*withCAP_SYS_ADMIN→ Pass- DAC layer returns Pass (skip) for all metadata ops
- ACL layer returns Pass (skip) for all metadata ops
- SELinux maps metadata ops to
file:setattr - Cascade:
GrantCapfix resolves metadata failure - Cascade:
Chownfix resolves chmod-as-non-owner failure
integration tests (Docker / Proxmox VMs)
- Real
chmodas non-owner on ext4 → gather + check produces correct Fail - Real
chmodas non-owner withCAP_FOWNERset viasetcap→ gather + check produces Pass - Real
chownas non-root withoutCAP_CHOWN→ Fail - Real
setxattr trusted.fooas non-root → Fail - Fedora (SELinux enforcing):
chmodon a confined process →file:setattrdenial detected - Ubuntu (AppArmor enforce):
chmodunder confined profile → Degraded with capability hint