engineering change order
v0.4
title whyno v0.4.0 — metadata permission operations
status accepted
date
family whyno-metadata-ops
project whyno
rustarchitecturelinuxcapabilitiespermissions

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 / utimesfile: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:

  • chmod by non-owner → requires capability fowner in the profile
  • chown → requires capability chown in the profile
  • setxattr on trusted.* / security.* → requires capability 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):

  1. If target.uid == subject.uidPass
  2. Else if has_cap(subject.capabilities, CAP_FOWNER)Pass with warning about setgid strip if applicable
  3. 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):

  1. If has_cap(subject.capabilities, CAP_CHOWN)Pass
  2. Else → Fail: "changing file UID requires CAP_CHOWN"

check_chown_gid rules (mirrors setattr_prepare for ATTR_GID):

  1. If params.new_gid is NoneDegraded: "new_gid required for ChownGid check — pass --new-gid"
  2. If has_cap(subject.capabilities, CAP_CHOWN)Pass
  3. Else if target.uid == subject.uid AND subject.in_group(params.new_gid)Pass
  4. Else if target.uid != subject.uidFail: "subject (uid=X) is not the file owner (uid=Y) and lacks CAP_CHOWN"
  5. 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 OR CAP_FOWNER
  • TrustedCAP_SYS_ADMIN
  • SecurityCAP_SYS_ADMIN
  • SystemPosixAcl → owner check OR CAP_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 call capability_modify_dac() only for rwx operations

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:

  1. Remove the #[cfg(test)] gate from checks/selinux.rs::operation_to_selinux_perm() and promote it to pub(crate)
  2. Extend it with the new setattr mappings
  3. Delete the duplicate in gather/mac.rs — the gather layer imports and calls the canonical copy from whyno-core instead

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:

  1. FixAction::Chown — transfer ownership to subject (impact 4)
  2. FixAction::GrantCap { cap: "cap_fowner" } — grant CAP_FOWNER to subject's executable (impact 5)

For ChownUid / ChownGid failures:

  1. FixAction::GrantCap { cap: "cap_chown" } (impact 5)

For SetXattr failures (namespace-dependent):

  1. User namespace: FixAction::Chown to transfer ownership (impact 4)
  2. Trusted/Security: FixAction::GrantCap { cap: "cap_sys_admin" } (impact 6 — high-impact warning)
  3. SystemPosixAcl: FixAction::Chown or FixAction::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>XattrNamespace parsed 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/chown EPERM is ubiquitous in container deployments, NFS mounts, and rootless setups
  • Activates dormant code — CAP_FOWNER, CAP_CHOWN constants and FixAction::Chown already 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_FSETID and CAP_SYS_ADMIN complete the set of file-permission-relevant capabilities

negative

  • CoreLayer enum grows to 6 variants — the EnumMap approach scales, but the pipeline and fix engine become more complex
  • Operation enum gains a variant with data — SetXattr { namespace } breaks the current pattern of simple unit variants; all match arms across the codebase need updating
  • Cascade simulation complexity — GrantCap needs capability bitmask math; Chown needs 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 setting security.selinux xattr
  • utimes / futimens — hybrid ownership-or-write-bit check, lower priority
  • AppArmor capability rule query — requires linking libapparmor and calling aa_query_label()
  • Namespace-aware capability checks — in user namespaces, CAP_CHOWN and CAP_FOWNER are scoped to the namespace

implementation order

  1. caps.rs — Add CAP_FSETID and CAP_SYS_ADMIN constants
  2. operation.rs — Add Chmod, ChownUid, ChownGid, SetXattr variants + XattrNamespace enum + is_metadata()
  3. checks/metadata.rs — New module with check_metadata() and sub-checks
  4. checks/dac.rs — Add early return for metadata ops
  5. checks/acl.rs — Add early return for metadata ops
  6. checks/mod.rs — Add CoreLayer::Metadata, wire into pipeline
  7. checks/selinux.rs — Promote operation_to_selinux_perm() to pub(crate), extend, delete duplicate in gather/mac.rs
  8. checks/apparmor.rs — Add metadata-aware degraded message for enforce mode
  9. fix/mod.rs — Add FixAction::GrantCap, collect_metadata_fixes()
  10. fix/generators.rs — New metadata_fixes() generator
  11. fix/cascade/apply.rs — Implement GrantCap and Chown apply arms
  12. whyno-gather — Add --new-uid, --new-gid, --xattr-key flags
  13. whyno-cli — Wire new --op values
  14. Tests — Unit tests per module, integration tests via StateBuilder extensions

test strategy

unit tests (synthetic state via StateBuilder)

  • chmod as owner → Pass
  • chmod as non-owner without CAP_FOWNER → Fail
  • chmod as non-owner with CAP_FOWNER → Pass + setgid strip warning
  • chown-uid without CAP_CHOWN → Fail
  • chown-uid with CAP_CHOWN → Pass
  • chown-gid as owner in target group → Pass
  • chown-gid as owner not in target group → Fail
  • chown-gid with CAP_CHOWN → Pass regardless of group membership
  • setxattr user.* as owner → Pass
  • setxattr user.* as non-owner → Fail
  • setxattr trusted.* without CAP_SYS_ADMIN → Fail
  • setxattr security.* with CAP_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: GrantCap fix resolves metadata failure
  • Cascade: Chown fix resolves chmod-as-non-owner failure

integration tests (Docker / Proxmox VMs)

  • Real chmod as non-owner on ext4 → gather + check produces correct Fail
  • Real chmod as non-owner with CAP_FOWNER set via setcap → gather + check produces Pass
  • Real chown as non-root without CAP_CHOWN → Fail
  • Real setxattr trusted.foo as non-root → Fail
  • Fedora (SELinux enforcing): chmod on a confined process → file:setattr denial detected
  • Ubuntu (AppArmor enforce): chmod under confined profile → Degraded with capability hint