Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Central Access Policies

Single-page view · as markdown

Central access policies

Peios / Peios Security Fundamentals / Central Access Policies

A central access policy — CAAP for short — is access policy defined once, centrally, and applied to many objects without per-object configuration. Where a DACL says "this is the policy for this object", a CAAP says "this is the policy for every object that references this policy". Objects reference a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in their SACL, naming the policy's SID. When the access check evaluates the object, it looks up each referenced policy and runs its rules in addition to the object's own DACL.

The model is conservative by design: CAAP never widens access — it only narrows. Whatever the object's DACL would have granted, a CAAP can take away. Nothing a CAAP says can let a caller through that the DACL would have denied. This makes CAAP safe to layer: you can apply a policy to many objects without worrying about it accidentally granting access that the underlying ACLs intended to refuse.

This page covers what CAAP is, when it makes sense to use, and the model at a conceptual level.

What CAAP solves #

The problem CAAP exists for: organisations whose access rules span many objects in a way that does not fit the per-object DACL model. Examples:

  • "Files classified Top Secret can only be read by users in the Cleared group, regardless of what the file's individual DACL says."
  • "Database records belonging to the EU customer base require GDPR-compliant access rules, applied uniformly."
  • "Engineering documents must be readable only on devices with up-to-date security software."

In each case, the rule is defined once at the organisational level. Each object that the rule applies to does not need to know the rule itself; it just needs to reference the policy by SID. The policy's content is stored centrally (in the directory or registry, distributed by authd) and applied at access-check time.

If you tried to express these rules directly in object DACLs, you would have to update every covered object whenever the rule changed. With CAAP, the policy is updated once and the next access check on any referencing object sees the new version.

How an object references a policy #

An object opts into a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in its SACL. The ACE has a single SID — the policy's identifier. Multiple SYSTEM_SCOPED_POLICY_ID_ACE entries in one SACL apply multiple policies; each is looked up independently and each contributes its own restriction to the access check.

The SACL is where the reference lives because the policy reference is system-level information — not something the object's owner should be able to override unilaterally. Modifying the SACL requires ACCESS_SYSTEM_SECURITY, which is privilege-gated (see The SACL). An object owner can rewrite the DACL freely; they cannot add or remove CAAP references without administrative authority.

Inherit-only SYSTEM_SCOPED_POLICY_ID_ACE entries are skipped during evaluation, just like other inherit-only ACEs. Their purpose is to propagate the policy reference to child objects at creation time.

The model in one paragraph #

A CAAP is a named bundle of rules. Each rule has:

  • An applies-to expression (optional) — a conditional expression evaluated against the object's resource attributes. If it evaluates to TRUE, the rule applies to this object; if to FALSE or UNKNOWN, the rule is skipped. If the expression is absent, the rule applies to every object that references the policy.
  • An effective DACL — a DACL whose rules are run against the calling token via a recursive sub-AccessCheck. The result is intersected with the running grant from the rest of the pipeline. A bit granted by the rule's DACL is preserved; a bit not granted is dropped.
  • An effective SACL (optional) — audit ACEs merged into the access check's SACL walk, so the policy can contribute its own audit rules.
  • A staged DACL and staged SACL (both optional) — proposed replacements for the effective DACL and SACL, evaluated in parallel without affecting access. Used for policy testing. See Staged policies.

The full rule structure is in Policies and rules. The evaluation mechanics — how rules compose, where they fire in the access pipeline, the no-recursion rule — are in Evaluation.

The narrowing-only guarantee #

Every CAAP rule's effective DACL is intersected with the running grant. The intersection is conjunctive: only bits granted by both sides remain. A bit the rule's DACL would not grant is dropped, regardless of how the object's own DACL had decided it.

This makes CAAP safe by construction. You cannot accidentally write a CAAP that grants something. The most permissive CAAP rule possible would be one whose effective DACL grants GENERIC_ALL to Everyone — and even that does not grant anything to a caller whose underlying DACL did not. The intersection is the floor of safety: CAAP can only ever restrict, never relax.

The implications:

  • Adding a CAAP to an existing object cannot increase access. Worst case, the new policy contributes nothing additional. Best case, it restricts access in the intended way.
  • A misconfigured CAAP cannot grant. It can only deny too aggressively. A bug in a policy's effective DACL means objects become harder to reach, not easier.
  • The order of CAAP evaluation does not matter. Intersection is commutative. Two CAAP rules on one object produce the same final grant whether you evaluate rule A then rule B or B then A.

This is the property that makes CAAP suitable as a deployment-wide policy mechanism. Administrators can layer policies without coordinating because the layers can only narrow.

Multiple policies, multiple rules #

A single SACL can include several SYSTEM_SCOPED_POLICY_ID_ACE entries, each referencing a different policy. Each policy can contain several rules. All of them apply. The running grant from the rest of the pipeline is intersected against each rule's effective DACL in turn. The final grant is what survives every applicable rule across every applicable policy.

In practice, a single object is usually covered by zero or one policy. Multiple policies on the same object means the policies cover orthogonal axes — "Classification" plus "Compliance", say — where each axis is a separate policy. The CAAP author keeps each policy's rules focused on one axis and lets layering produce the combined effect.

Check-at-open semantics #

CAAP changes are applied at the next access check. Already-open handles do not see CAAP changes. A handle opened ten minutes ago has its granted mask cached on the file descriptor; the access check that granted it was the one that mattered, and a CAAP update afterwards has no effect on that handle.

This is the standard check-at-open model that File access (FACS) uses for all of access control. A user wanting to be sure a CAAP change has taken effect must close existing handles and reopen.

The implication for policy rollout: updating a CAAP does not immediately revoke access from sessions that already have open handles. If the policy update is meant to be enforced retroactively, the deployment needs an out-of-band step — typically session-revocation or service-restart — to drop the existing handles.

The CAAP catalog lives in the kernel #

The kernel maintains a policy cache keyed by policy SID. The cache is populated by authd at startup, and updated by kacs_set_caap calls when policies change. At access-check time, looking up a referenced policy is a constant-time lookup against this cache.

The cache is empty at boot. Until authd populates it, every CAAP reference resolves to "policy not found" and the recovery policy fires (covered in Distribution and recovery). This is why authd populates the cache early in boot, before user-facing services start.

Where to start #

If you want the structure of a policy — the rule layout, applies-to expressions, effective and staged DACL/SACL — read Policies and rules.

If you want to know how CAAP fires in the access pipeline and the rules for composition, read Evaluation.

If you want to understand the testing model — staged policies that evaluate in parallel without affecting access — read Staged policies.

If you want the operational story — how policies get into the kernel, who calls kacs_set_caap, what happens when a referenced policy is missing — read Distribution and recovery.

Policies and rules

Peios / Peios Security Fundamentals / Central Access Policies

A central access policy is a structured bundle: a version byte, a count of rules, and the rules themselves. Each rule is independently scoped — its applies-to expression decides whether this rule fires on this object — and contributes its own access restriction and audit rules. A policy with three rules can apply to one object via one rule, to another object via two rules, and to a third object via none, depending on the objects' resource attributes.

This page walks through the structure: what a policy contains, what a rule contains, and how the parts fit together. The mechanics of how the policy is actually evaluated during AccessCheck live in Evaluation.

A policy in shape #

A policy in its on-wire form is a versioned binary blob:

FieldMeaning
VersionA single byte. v0.20 uses 0x01. Any other value is rejected at ingestion.
Rule countA 32-bit little-endian count of the rules that follow. Bounded to 256 rules per policy.
RulesLength-prefixed sequence — one entry per rule.

A policy is not a thing the kernel walks through ACE by ACE. It is a list of rules, each evaluated as its own unit. Two rules in the same policy do not influence each other; either both apply (their applies-to expressions both evaluate TRUE) or one applies or neither.

The kernel holds the parsed policy in its policy cache, keyed by the policy's SID (the SID is established at the time the policy is pushed via kacs_set_caap — see Distribution and recovery).

A rule in shape #

Each rule is a small structured record:

FieldMeaning
applies_toOptional. A length-prefixed conditional expression in the same bytecode used by callback ACEs. If present, the expression must evaluate TRUE for this rule to apply to a given object; if absent, the rule applies to every object that references the policy.
effective_daclA length-prefixed binary DACL. Required (a rule with no DACL would do nothing). Maximum 65,535 bytes.
effective_saclOptional. A length-prefixed binary SACL contributing audit and policy ACEs.
staged_daclOptional. A proposed replacement for effective_dacl, evaluated in parallel for testing.
staged_saclOptional. Same idea for effective_sacl.

Length-prefixed means each part starts with a 32-bit count of bytes followed by that many bytes. An absent field has length zero. The wire-format details are in the Wire formats reference; for this page, what matters is the structure.

A rule with no applies-to expression and only an effective_dacl is the most common shape. It says "apply this DACL to every object that references this policy".

The applies-to expression #

The applies_to expression is a conditional-ACE bytecode expression — the same model as conditional ACEs in DACLs and SACLs (see Conditional ACEs). The expression has access to four namespaces:

  • @User.<name> — claims on the calling token.
  • @Device.<name> — claims on the calling token's machine identity.
  • @Resource.<name> — resource attributes on the object being accessed.
  • @Local.<name> — per-call attributes the caller supplied.

The expression evaluates to TRUE, FALSE, or UNKNOWN. The CAAP rule:

ResultEffect on the rule
TRUEThe rule applies. The effective DACL is evaluated.
FALSEThe rule is skipped.
UNKNOWNThe rule is skipped.

The UNKNOWN behaviour is opposite to the conditional-ACE deny rule. In a DACL, an UNKNOWN expression on a deny ACE causes the deny to apply (fail-closed on deny). For a CAAP applies-to, UNKNOWN causes the rule to be skipped (fail-open on the rule's existence). The reason: a rule that does not apply means the running grant is not further restricted; skipping is the conservative choice for CAAP, because the absence of a restriction is the safe default.

This split is worth remembering. The rule of thumb: UNKNOWN always errs in whichever direction does not narrow access. In DACLs, that means UNKNOWN denies are denials (because denying is the existing direction the ACE is going); in CAAP applies-to, that means UNKNOWN rules are skipped (because applying the rule would further narrow access).

Common applies-to expressions:

Example expressionWhat it does
(absent)Rule applies to every object that references the policy.
@Resource.Classification == "TopSecret"Rule applies only to objects whose Classification resource attribute is TopSecret.
@Resource.Department == "Engineering" && @Resource.Sensitivity == "Internal"Rule applies only when both attributes match.
Exists(@Resource.RetentionUntil) && @Resource.RetentionUntil > @Local.NowRule applies only to objects whose retention period has not expired.

The expression can reference any combination of the four namespaces. It produces a single boolean; whatever combination of attributes you need to inspect, the expression is the place to do it.

The effective DACL #

The effective_dacl is a regular DACL — an ACL of ACEs, with the same format any other DACL uses (see ACLs, ACEs, and access masks). It is evaluated via a recursive sub-AccessCheck at policy-evaluation time, producing a granted mask for the calling token.

The mask is then intersected with the running grant from the rest of the pipeline. The intersection is the narrowing: bits granted by the rule's effective DACL are preserved; bits not granted are dropped.

The rule's effective DACL can contain any ACE types a regular DACL can. Conditional ACEs work — referencing claims, attributes, and local context. Object ACEs work — scoping by property GUID. Generic rights work — mapped via the object type's GenericMapping table at evaluation time, exactly as they would be in a regular DACL.

What the effective DACL cannot do: include SYSTEM_SCOPED_POLICY_ID_ACE references to other policies. Even if such ACEs were present, the kernel's no-recursion rule strips them before evaluating the synthetic SD (see Evaluation). CAAP rules do not nest.

The maximum size of an effective DACL is 65,535 bytes — the same limit a regular DACL has.

The effective SACL #

The effective_sacl is optional. When present, it contributes its ACEs to the access check's SACL walk for the object being evaluated. This lets a policy contribute audit rules that fire alongside the object's own audit ACEs.

The audit ACEs in an effective SACL fire based on the final access decision, not on the policy's contribution to it. An audit ACE in an effective SACL that matches a successful access fires as a "successful access" event, regardless of whether the success came from the object's own DACL or from any specific policy.

This is the audit half of CAAP. A policy that wants to log every access to objects it covers — for regulatory or compliance reasons — does so by including an audit ACE in its effective SACL. The audit ACE applies wherever the rule applies, without the object's own administrator needing to add an audit ACE on every object.

The effective SACL can also contain SYSTEM_MANDATORY_LABEL, SYSTEM_PROCESS_TRUST_LABEL, and SYSTEM_RESOURCE_ATTRIBUTE ACEs — but they are not respected at policy evaluation. CAAP can only contribute to audit. It cannot apply a mandatory label or PIP label to an object through this channel. Those entries, if present, are ignored.

Staged DACL and SACL #

The staged_dacl and staged_sacl fields hold proposed replacements for the corresponding effective entries. They are evaluated in parallel during AccessCheck but do not affect the granted mask. Their purpose is to let administrators test policy changes against real traffic without committing.

If the staged DACL would have produced a different granted mask, or the staged SACL would have produced a different set of audit events, the access check sets a staging mismatch flag in its output. Tools watching for mismatches can use the flag to discover where a proposed policy change would have changed behaviour.

The full mechanics — when staging is used, what the mismatch flag actually contains, how rollout proceeds — are in Staged policies.

A complete rule, in shape #

Putting it all together, a CAAP rule for "TopSecret objects can only be read by users in the Cleared group" might look like:

FieldValue
applies_to@Resource.Classification == "TopSecret"
effective_daclAn ACL containing: ACCESS_ALLOWED Cleared-Group GENERIC_READ, and nothing else.
effective_saclAn ACL containing: SYSTEM_AUDIT_ACE Everyone GENERIC_READ with `SUCCESSFUL_ACCESS_ACE_FLAG
staged_dacl(absent)
staged_sacl(absent)

A user not in the Cleared group attempting to read a TopSecret object: the rule applies (attribute matches), the effective DACL grants GENERIC_READ only to the Cleared group, the user is not in the group so the rule grants nothing, the intersection drops GENERIC_READ from the running grant. Access denied. The audit ACE in the effective SACL records the attempt.

A user in the Cleared group attempting to read the same object: the rule applies, the DACL grants GENERIC_READ to the Cleared group, the user is in the group, the rule contributes GENERIC_READ to the intersection, the running grant survives, access succeeds. The audit ACE still records the access.

A user (anyone) attempting to read an object whose Classification is "Internal", not "TopSecret": the rule's applies-to evaluates FALSE, the rule is skipped, the running grant passes through unchanged. CAAP contributes nothing on this access.

Limits #

For reference, the size and count limits the kernel enforces at ingestion:

LimitValue
Wire-format spec size256 KB
Rules per policy256
Bytes per applies-to expression64 KB
Bytes per effective or staged DACL/SACL65,535 (matches the SD format limit)
Version byteMust be 0x01

A policy exceeding any of these is rejected by kacs_set_caap with -EINVAL. The limits are generous; a realistic policy will be far below them.

Where to go next #

For how a policy's rules are actually evaluated during AccessCheck — the flow, the no-recursion rule, and the composition — read Evaluation.

For the staged DACL and SACL fields and the rollout pattern they enable, read Staged policies.

For the byte-level layout these structures are encoded in, read CAAP format.

Evaluation

Peios / Peios Security Fundamentals / Central Access Policies

CAAP evaluation is step 12 of the access-check pipeline. By this point the DACL walk has run, owner implicit rights have been applied, restricted-token and confinement passes have done their work, and the running grant is whatever survived. CAAP can only narrow this grant further — it never adds.

The mechanism is the same shape every narrowing layer uses: re-walk the DACL against a different identity (or DACL), intersect, move on. The difference for CAAP is what gets walked: each referenced policy's rules are evaluated, and each applicable rule's effective DACL is intersected with the running grant.

This page covers the evaluation flow, the no-recursion rule that prevents CAAP from referencing other CAAPs, and the composition with the rest of the pipeline.

The evaluation flow #

flowchart LR
    A["Running grant after step 11"] --> X["For each SYSTEM_SCOPED_POLICY_ID_ACE in SACL"]
    X --> L["Look up policy in cache"]
    L -->|found| R["For each rule in policy"]
    L -->|not found| Y["Apply recovery policy"]
    R --> T["Evaluate applies-to"]
    T -->|TRUE| E["Evaluate effective DACL against token"]
    T -->|FALSE / UNKNOWN| S["Skip rule"]
    E --> I["Intersect with running grant"]
    Y --> I
    I --> N["Next rule or policy"]
    N --> F["Final running grant after step 12"]

In code-shape order:

  1. Scan the object's SACL for SYSTEM_SCOPED_POLICY_ID_ACE entries that are not inherit-only.
  2. For each such ACE, take the SID and look it up in the kernel's policy cache.
  3. If the policy is not in the cache, apply the recovery policy — a hardcoded fallback that grants GENERIC_ALL only to BUILTIN\Administrators, SYSTEM, and OWNER_RIGHTS. The intersection happens just like any other CAAP rule's would. See Distribution and recovery.
  4. If the policy is in the cache, iterate through its rules.
  5. For each rule, evaluate the applies_to expression against the caller's token, the object's resource attributes, and the local claims supplied by the caller. If the expression evaluates to TRUE, the rule applies; if FALSE or UNKNOWN, the rule is skipped.
  6. For each applicable rule, evaluate the effective_dacl against the calling token. This is a recursive sub-AccessCheck — the rule's DACL is walked against the token the same way a regular DACL would be.
  7. Intersect the rule's grant with the running grant. Bits not present in both are dropped from the running grant.
  8. Collect the rule's effective_sacl for the eventual audit walk at step 14.
  9. Also evaluate the staged DACL/SACL if present, in parallel. The staged evaluation does not affect the running grant — it only contributes to the staging mismatch flag if its result differs. See Staged policies.
  10. Move on to the next rule (or, when all rules are evaluated, the next policy).
  11. After every applicable rule from every applicable policy, the final running grant is what survives. This becomes the input to step 13.

The order does not affect the result. Intersection is commutative — two CAAP rules applied in either order produce the same final grant — so the kernel can evaluate rules in whatever order is convenient.

The recursive sub-AccessCheck #

When the kernel evaluates a rule's effective DACL, it constructs a synthetic SD to run the AccessCheck against:

  • The synthetic SD's DACL is the rule's effective_dacl.
  • The synthetic SD's owner is the object's actual owner. (Owner implicit rights still apply if the caller is the owner — a CAAP cannot suppress owner implicit grants.)
  • The synthetic SD's primary group is the object's actual primary group.
  • The synthetic SD's SACL is stripped of any SYSTEM_SCOPED_POLICY_ID_ACE entries before evaluation. This is the no-recursion rule.

The recursive AccessCheck then runs against this synthetic SD with the same token and the same desired mask. It produces a granted mask. That mask is what gets intersected with the running grant.

The no-recursion rule #

The synthetic SD's SACL has its SYSTEM_SCOPED_POLICY_ID_ACE entries stripped before evaluation. This is what stops a CAAP from referencing another CAAP.

Without this rule, a CAAP rule's effective DACL could contain — or its synthetic SD's SACL could imply — references to additional policies, which would themselves be evaluated, which could reference further policies, and so on. The chain has no natural bound. The kernel addresses this by simply refusing to follow any CAAP reference inside a CAAP evaluation: the synthetic SD's SACL has its scoped-policy ACEs erased, so the recursive AccessCheck sees no CAAP references.

A reasonable question: what if a CAAP author wants their policy to compose with another? The answer is to put both SYSTEM_SCOPED_POLICY_ID_ACE entries in the object's SACL, not nest them. The two policies will be evaluated as siblings — each is intersected with the running grant — and the order does not matter because intersection is commutative. The effect is the same as nesting would have produced but without the recursion concern.

Composition with other narrowing layers #

CAAP is the last narrowing layer in the pipeline. By the time it runs at step 12:

  • The DACL walk (step 8) has produced the initial grant from the object's own DACL.
  • The restricted-token pass (step 10), if active, has intersected against the restricted-SID-only view.
  • The confinement pass (step 11), if active, has intersected against the confinement identity.

CAAP then intersects against each applicable rule's effective DACL. The running grant survives all four (the DACL walk plus three intersections) if and only if every layer permitted the bit.

The composition is conjunctive: each layer must independently grant the right for it to remain. Adding CAAP to a token already restricted and confined further narrows; it cannot bring back access the earlier layers stripped.

Privilege grants are subject to CAAP intersection, like other bits. A CAAP rule whose effective DACL does not grant a right will strip that right from the running grant even if a privilege had granted it in step 4 or step 9. CAAP is privilege-blind in the same way confinement is.

See Narrowing layers for the full composition.

CAAP and audit #

The audit contribution of CAAP — the effective_sacl of each applicable rule — is collected during step 12 but not consumed until step 14, the SACL audit walk. The audit ACEs in CAAP effective SACLs are added to the object's own SACL ACEs, and the audit walk treats the combined set as a single SACL for the purposes of deciding which events to fire.

Audit ACEs in a CAAP can target the same identities and produce the same kinds of events as regular SACL audit ACEs. The CAAP contribution does not get its own event type or its own audit log entry — the events look like any other audit events, except that the matched ACE happens to have come from a policy rather than the object's own SACL.

This means the audit pipeline does not need to know about CAAP specifically. From the perspective of an audit consumer, the events are the same. The provenance — which ACE matched, whether that ACE was on the object or in a CAAP — is recorded in the event's trigger field for consumers that care.

The full audit model is in Auditing.

Errors during evaluation #

A handful of failure modes can occur during CAAP evaluation. The kernel handles each one specifically:

ConditionBehaviour
Referenced policy SID not in cacheApply the recovery policy. The running grant is intersected with what the recovery policy would grant. The recovery policy is administrator-friendly: it grants GENERIC_ALL to Administrators, SYSTEM, and OWNER_RIGHTS only.
Policy in cache but malformedThe same as not in cache — recovery policy applies. (kacs_set_caap should reject malformed policies at ingestion, but defence in depth.)
Applies-to expression returns UNKNOWNRule is skipped. (Different from a deny-ACE's UNKNOWN, which would apply — see Policies and rules for the asymmetry rationale.)
Effective DACL evaluation produces an errorTreated as "this rule denies everything except privilege-granted bits". The running grant is reduced to whatever privileges had pre-decided as granted.

The pattern: errors fail-closed (i.e. they reduce the running grant, not extend it). A malformed policy or a corrupted effective DACL cannot accidentally grant access. The worst case is denial of access, with the recovery policy ensuring administrators retain a way back in.

Check-at-open #

CAAP evaluation runs only during AccessCheck calls. A handle that was opened ten minutes ago has its granted mask cached on the file descriptor; the access check that decided it ran at open time. A subsequent CAAP update does not retroactively affect that handle.

For FACS-managed objects (files, directories), the granted mask is cached on the open handle and used for subsequent operations against the handle. The mask is not recomputed. See The handle model.

For non-FACS objects that re-evaluate AccessCheck on each operation (some IPC endpoints, some token operations), the CAAP update would be visible immediately on the next operation.

The user-visible effect: changing a CAAP changes what new accesses see. It does not change what existing handles can do. Tools that need a CAAP update to be visible immediately need to coordinate session revocation or service restart — see Distribution and recovery.

Where to go next #

For the parallel evaluation of proposed policy changes and the staging mismatch flag, read Staged policies.

For how policies reach the kernel's cache and what happens when a referenced policy is missing, read Distribution and recovery.

For how the CAAP intersection composes with the restricted-token and confinement passes, read Narrowing layers.

Staged policies

Peios / Peios Security Fundamentals / Central Access Policies

Rolling out a change to a central access policy is operationally risky in a way that local DACL changes are not. A DACL change affects one object; if it is wrong, one object misbehaves. A CAAP change affects every object that references the policy — potentially thousands of files, registry keys, or service endpoints. An overly-permissive change is a security incident; an overly-restrictive change is a system-wide outage.

Staged policies are the testing primitive that makes CAAP rollout safe. A CAAP rule can carry, alongside its effective_dacl and effective_sacl, a proposed replacement called the staged_dacl and staged_sacl. During AccessCheck, the kernel evaluates both versions in parallel. The effective version is what affects the granted mask; the staged version does not. But the kernel records whether the staged version would have produced different behaviour, and reports that as a staging mismatch flag in the access check's output.

A staging mismatch is a signal: "if this staged change had been live, this access would have gone differently". Administrators can collect mismatches over a representative period of access traffic and make an informed decision about whether to commit the staged version.

This page covers how staging works mechanically, what the mismatch flag means, and the rollout pattern it enables.

How staging fits into evaluation #

When the kernel evaluates a CAAP rule at step 12 of the pipeline:

  1. The rule's applies-to expression is evaluated. If it returns FALSE or UNKNOWN, the rule is skipped entirely; no effective evaluation, no staged evaluation.
  2. If the applies-to returned TRUE, the effective_dacl is evaluated against the calling token. The result is intersected with the running grant.
  3. If the rule has a staged_dacl, the kernel also evaluates it against the same token. The result is not intersected with anything; the running grant uses only the effective result.
  4. The kernel compares the staged result against the effective result. If they differ — that is, the staged DACL would have granted a different mask than the effective DACL did — the access check's staging mismatch flag is set.
  5. The same comparison happens for the staged_sacl versus effective_sacl: if the staged audit ACEs would have produced different events (a different set of audits fired, or different bits triggered them), the staging mismatch flag is also set.

The mismatch flag is a boolean per access check. It does not say which rule or policy caused the mismatch; that level of detail would require richer reporting. What it does say is: somewhere in the CAAP layer of this access check, a staged change would have made a difference.

Tools watching the flag can correlate it with the call's parameters (which token, which object, which requested rights) to localise where staging would have changed behaviour. This is enough for an administrator to make a go/no-go decision on a policy rollout.

What the mismatch flag is and is not #

The flag is informational. It does not change behaviour — the access check returned the granted mask the effective policy produced, and the caller's operation proceeds accordingly. The flag is for observers (audit systems, deployment tools, monitoring dashboards).

The flag is boolean. It says "yes, somewhere a staged version differed from the effective" or "no, the staged versions all produced identical results". It does not enumerate which rules differed or which audit events changed; the accompanying caap-policy-diagnostic event records only the effective and staged total granted masks. Richer staging diagnostics are a job for the test pipeline (running representative AccessCheck calls and recording the differences); the kernel's report is the existence of a difference, not its detail.

The flag is per access check. Each access check call produces its own flag value in its own output. Aggregating mismatches across many calls is the consumer's job.

The DACL mismatch #

A DACL staging mismatch is set when the staged_dacl produces a different granted mask than the effective_dacl for the same call.

The comparison is bit-exact. If the effective DACL would grant FILE_READ_DATA | FILE_READ_ATTRIBUTES and the staged DACL would grant only FILE_READ_DATA, that is a mismatch. If both would grant the same bits, no mismatch. Bits not present in either grant are irrelevant.

The asymmetry is honest: a staged DACL more permissive than the effective would also trigger a mismatch. Staging is not about "this would deny more"; it is about "this would behave differently".

The SACL mismatch #

The SACL mismatch is more subtle because SACL evaluation produces audit events, not granted bits. The staged_sacl mismatch is set when the staged SACL would have produced a different set of audit events for the same call than the effective SACL did.

"Different set" means: the union of events that would have fired from the staged SACL is not equal to the union from the effective SACL. An event in one but not the other, or vice versa, is a mismatch. This includes audit ACE matches that would have triggered, and conditional audit ACEs whose conditional expressions differ.

The mismatch does not record which events differed. The kernel compares the two event sets internally and discards them; the caap-policy-diagnostic event (kind = staging-mismatch) it emits carries only the effective and staged total granted masks and an object_results_differ boolean. For a pure SACL mismatch those masks may even be equal — the flag is the only signal that the audit behaviour differed.

The rollout pattern #

The intended deployment flow for a CAAP change:

  1. Author the staged change. Take the existing policy. Add or modify the staged_dacl and/or staged_sacl to reflect the proposed change. Leave the effective versions unchanged.
  2. Distribute the policy. authd pushes the policy (with staged versions populated) to the kernel via kacs_set_caap. Other machines in the deployment get the same policy through their authd instances.
  3. Run for a period. The system runs normally. Every access check produces a granted mask; tokens behave as if the effective version is in force. The mismatch flag is set whenever the staged version would have produced different behaviour.
  4. Collect mismatch reports. A monitoring tool records mismatches and their contexts (which call, which token, which object, which requested rights). Administrators review the report.
  5. Decide. If the mismatches reflect intended behaviour changes (the change tightens access in exactly the cases the administrator expected to tighten), the change is good. If unexpected accesses are flagged, the change has unintended side effects and needs revision.
  6. Commit. Replace the policy with one where effective_dacl and effective_sacl hold what the staged versions held. Push via kacs_set_caap. The change is now live; subsequent access checks use the new effective version.
  7. Optionally, drop the staging fields. Once the change is committed, the policy can be re-pushed without staged fields, or with new staged fields representing the next planned change.

This flow lets administrators see the effect of a CAAP change on real traffic before committing. The mismatch flag turns "what will this change do?" from a question requiring synthetic test data into an observation of the actual system.

Staging applies to one rule, not whole policies #

Each rule in a policy can have its own staged DACL and SACL. The staging is per-rule, not per-policy. A policy with three rules can have one rule staging a change while the other two stay aligned (no staged version, or staged equal to effective). The mismatch is set whenever any active rule's staged version differs from its effective version.

Operationally, this is exactly what you want. A policy administrator typically wants to stage one change at a time, not stage the whole policy. The rule-level granularity lets each change be reasoned about independently.

What staging does not do #

A few clarifications:

  • Staging does not affect access. The staged DACL never contributes to the granted mask. Whatever the effective version returns is what the caller gets.
  • Staging does not affect audit events. The staged SACL does not fire events; only the effective SACL does. The staged SACL is evaluated only for the comparison.
  • Staging is not versioning. There is no "previous staged" or "next staged". Each rule has at most one staged DACL and one staged SACL. Replacing them replaces them.
  • Staging is not a rollback mechanism. If a committed change turns out to be wrong, the rollback is to re-author the policy with the old version and push it again. The staging fields are forward-looking, not backward-looking.

How staging changes for the audit consumer #

An audit consumer that wants to use staging meaningfully should look for the staging mismatch flag in audit events. The flag's presence is a signal to investigate; the access in question would have been decided differently under the proposed change.

Aggregating mismatches over time and bucketing them by (token, object class, requested rights) gives the administrator a picture: "the proposed change would have denied N accesses, of which M look intentional and (N-M) look unintentional". From there, deciding whether to commit is straightforward.

Without staging, the equivalent diagnostic is impossible without either a parallel test system or an A/B rollout — both expensive. Staging makes the comparison free per access check.

Where to go next #

For how a committed policy is pushed to the kernel — and the recovery behaviour when a referenced policy is missing — read Distribution and recovery.

For the caap-policy-diagnostic event that carries the staging mismatch, read Events and transport.

Distribution and recovery

Peios / Peios Security Fundamentals / Central Access Policies

A central access policy that no one has loaded into the kernel does not affect access checks. The kernel maintains a policy cache — a map from policy SID to parsed policy — that AccessCheck consults at step 12. Distributing a policy means getting it into this cache. The mechanism is the kacs_set_caap syscall, called by authd.

This page covers how policies get into the kernel, what happens when a referenced policy is missing, and the boot-time sequence that makes the model usable.

kacs_set_caap #

The syscall is straightforward:

kacs_set_caap(policy_sid, sid_len, spec_or_null, spec_len)
ParameterMeaning
policy_sidThe SID identifying the policy. A 4–68 byte binary SID. Establishes (or replaces) the cache entry.
sid_lenThe length of the SID in bytes.
spec_or_nullThe wire-format spec to install at this SID. NULL means "remove the policy at this SID from the cache".
spec_lenThe length of the spec in bytes. Zero when removing.

The call requires SeTcbPrivilege. The only callers in a running system are authd and peinit; ordinary processes cannot push policies.

The kernel:

  1. Validates the SID and the spec. A malformed spec (wrong version byte, truncated fields, too many rules, invalid applies-to bytecode) is rejected with -EINVAL. No partial installation; the call is atomic.
  2. Parses the spec into the cache's internal representation.
  3. Replaces any existing entry at the policy's SID with the new one. If no entry existed, creates one.
  4. Returns success.

The policy is now live. The next access check that references this policy SID will see the new version. Existing handles (their access checks already done) are unaffected, per the check-at-open model.

To remove a policy, the caller passes NULL for the spec. The kernel deletes the cache entry. Subsequent references to this SID will not find the policy and the recovery policy will apply.

Who calls kacs_set_caap #

authd is the primary caller. authd's job is the bridge between policy storage (the registry on a standalone machine, the domain's directory on a domain-joined one) and the kernel. On startup:

  • authd connects to its source of policy. Standalone: it reads from the registry. Domain-joined: it queries the domain's directory service.
  • For each policy in the source, authd parses the policy into the wire format and calls kacs_set_caap to install it.
  • After each policy change in the source (a registry write, a directory replication), authd re-pushes the affected policy via kacs_set_caap.

peinit can also call kacs_set_caap for early-boot policies that need to be in place before authd starts. This is rare — most of the time, the kernel's CAAP cache is empty until authd takes over.

There is no path for an ordinary application to push a CAAP. The privilege requirement (SeTcbPrivilege) and the architectural restriction (only authd and peinit hold it) mean policy authority is concentrated in one place.

The policy cache and its lifetime #

The cache is an in-kernel data structure with the following properties:

  • Empty at boot. The kernel does not persist the cache across reboots. Every boot starts with an empty cache.
  • Populated by call. authd (or peinit) pushes policies one at a time via kacs_set_caap.
  • Updated in place. A subsequent push at the same SID replaces the existing entry.
  • Removed by null spec. A push with a NULL spec deletes the entry.
  • Lives for the lifetime of the running kernel. A reboot starts over.

The cache is keyed by SID. There is no other index — no name, no category, no version. The SID is the canonical reference, and it must match between the object's SYSTEM_SCOPED_POLICY_ID_ACE reference and the cache entry.

What happens when a policy is missing #

A common case during early boot, or when a policy reference outlives the policy itself (the policy was removed but objects still reference it): the SACL of an object contains a SYSTEM_SCOPED_POLICY_ID_ACE whose SID does not appear in the policy cache. The kernel needs to do something.

The choice between failing open (treat the missing policy as "no restriction") and failing closed (treat the missing policy as "deny everything") is a security/usability trade-off. Failing open would let administrative misconfiguration silently disable security policies. Failing closed would render objects unreachable when a policy is briefly unavailable.

The kernel picks neither extreme. It applies a recovery policy — a hardcoded fallback that grants GENERIC_ALL only to:

  • BUILTIN\Administrators
  • SYSTEM
  • OWNER_RIGHTS (the owner of the object, but only if not suppressed by an OWNER RIGHTS ACE in the object's DACL)

The recovery policy is conservative in the sensible direction: ordinary users lose access to objects whose policy is missing, but administrators and SYSTEM retain enough authority to investigate, fix, and restore the policy. Owners can still reach their own objects (with the OWNER RIGHTS caveat).

The recovery policy fires automatically when the kernel cannot find a referenced policy. It applies the same way any other CAAP rule would — its grant is intersected with the running grant from the rest of the pipeline. The intersection ensures that the recovery policy cannot grant more than the DACL would have. In the typical case, a non-administrator user attempting to access an object covered by a missing policy gets nothing (DACL + recovery intersect to empty); an administrator gets whatever the DACL would have given them.

The recovery policy is not configurable. It is hardcoded into the kernel. The administrator-and-SYSTEM-only grant is the safe default; administrators who want a different fallback fix the policy distribution problem, they do not change what the recovery policy is.

The boot sequence #

The kernel's CAAP cache is empty at boot. Until authd starts and populates the cache, every access check that touches a CAAP-referencing object will get the recovery policy.

This is by design but it has a consequence: security-sensitive services that depend on CAAP must not start before authd is running. If a service that handles user requests starts before authd has loaded its policies, the service's access checks will use the recovery policy. For ordinary users accessing the service, that means denial of service. For administrators, that means working but not in the way the intended policies define.

The standard boot order:

  1. Kernel initialises. SYSTEM token created. Anonymous token created.
  2. peinit starts. peinit may push early-boot policies via kacs_set_caap if any exist.
  3. authd starts. authd connects to its policy source and pushes every policy via kacs_set_caap.
  4. authd signals that policy distribution is complete (typically by reaching a steady state).
  5. Other services start, knowing the CAAP cache is now populated.

The fourth and fifth steps require coordination — peinit, the service manager, and authd need to agree on when CAAP is ready. The exact mechanism is part of Boot and trust establishment.

If your service-start ordering does not respect this, the symptoms are: access denied for ordinary users, mysterious "policy not found" recoveries in the audit log, services that work for administrators but not for anyone else.

Updates and replication #

When a policy changes in the source (an administrator edits a registry value, or the directory replicates a new version), authd notices the change and re-pushes the affected policy via kacs_set_caap. The cache entry is updated atomically: subsequent access checks see the new version, ones already in progress see whichever version was current at the time they evaluated the relevant step.

There is no kernel notification mechanism for "this policy changed". Tools that want to react to a CAAP update need to subscribe to authd's events or watch the source directly. The kernel itself does not announce.

Replication across machines is authd's job, not the kernel's. The kernel knows nothing about other machines; it sees only the policies authd has pushed locally. On a domain-joined system, each machine's authd reads from the directory independently and pushes to its local kernel. The directory's replication latency becomes the propagation delay for CAAP changes across the deployment.

Cache eviction is administrative #

The kernel does not evict cached policies on its own. A policy stays in the cache until:

  • kacs_set_caap is called with the same SID and a NULL spec (explicit removal).
  • The kernel reboots (cache is wiped).

There is no LRU, no size-based eviction, no automatic cleanup. The cache grows or shrinks based only on explicit calls. This is intentional: a policy disappearing from the kernel cache because of memory pressure would be a security issue.

If memory is genuinely tight, the right behaviour is to keep the policies and reduce something else. The kernel's CAAP cache is not where the system's memory pressure should land.

What CAAP does not get from distribution #

A few things distribution does not handle, that are worth knowing:

  • Object-side enforcement. Distributing a policy makes the policy reachable when an object's SACL references it. The SACL still has to be set up — adding a SYSTEM_SCOPED_POLICY_ID_ACE to an object's SACL is a separate administrative action on each object (or a class of objects via a directory-management tool).
  • Audit propagation. A policy's audit events fire as part of the access check; they go to KMES like any other audit event. The distribution mechanism does not bundle a separate audit pipeline.
  • Per-user policies. A policy that should apply only to specific users is one whose effective DACL or applies-to expression filters by user identity. Distribution does not target specific users; every policy in the cache applies to whoever accesses an object that references it.

These are policy-author concerns, not distribution concerns. The distribution layer is simple: push policies, identified by SID, into a cache the kernel consults. Everything else is the policy author's responsibility.

Where to go next #

For what the kernel does with a cached policy on each access check, read Evaluation.

For the boot-time coordination that decides when the CAAP cache is ready, read Boot and trust establishment.

Peios Learn — documentation for the Peios project.

Built with Trail.