# Security Descriptors

---

# Security descriptors

_Peios / Peios Security Fundamentals / Security Descriptors_

> Every protected object carries one security descriptor — the owner, primary group, DACL, and SACL that define who may do what to it and what is audited.

A **security descriptor** (often just SD) is the policy attached to every protected object in Peios. Every file, every registry key, every IPC socket, every running process, every token — all of them carry an SD that defines the security policy for that one object. Where the [token](/peios/security-fundamentals/tokens/overview.md) is the "who" of an access decision, the SD is the "what to check against".

A security descriptor has four components, each with its own role, its own evaluation rules, and its own pitfalls. This topic covers each in turn.

## The four components

Every SD has exactly four parts:

| Component | What it does |
|---|---|
| **Owner** | A single SID identifying who owns the object. The owner has implicit READ_CONTROL and WRITE_DAC rights (with one exception, covered in [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md)). |
| **Primary group** | A single SID. Used when CREATOR_GROUP placeholder ACEs are substituted during inheritance. Rarely directly meaningful. |
| **DACL** (Discretionary Access Control List) | The ordered list of ACEs that decides who can do what to the object. This is the part most people mean when they say "the permissions on a file". Controlled by the owner via WRITE_DAC. |
| **SACL** (System Access Control List) | The list of system-level policy ACEs — audit rules, the object's integrity label, resource attributes, central access policy references, PIP trust labels. Controlled by anyone with ACCESS_SYSTEM_SECURITY (which is gated by [`SeSecurityPrivilege`](/peios/security-fundamentals/privileges/overview.md)). |

A given SD can have all four components or some subset. An SD without a DACL is not the same as an SD with an empty DACL — see [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md) for the rule. An SD without an owner is malformed; the access check rejects it.

## What an SD does not contain

Worth knowing what is not in an SD, because these things are often mistakenly attributed to it:

- **The object's content or data.** SDs say nothing about what the object is, only about who can use it.
- **The object's type.** Whether you are dealing with a file, a registry key, or a process is the object manager's concern. The SD is the same shape regardless; only the access rights it grants are object-type-specific (because the [GenericMapping](/peios/security-fundamentals/security-descriptors/acls-and-aces.md) for each type is different).
- **The token of the principal who is trying to access the object.** That comes from the caller, not the SD. An SD is policy; tokens are subjects; the access check matches them up.
- **Anything about the parent object.** Inheritance is computed at creation time — a child object's SD is its own complete value, not a delegation to the parent. See [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md) for the eager-evaluation model.

These boundaries matter because the SD's job is small and well-defined. Everything else about an object lives somewhere else: the data is in the data path, the type is in the object manager, the access decision is in the access check.

## Where SDs live

Different object types store SDs in different places, but the format and meaning are the same.

| Object type | Storage |
|---|---|
| Files and directories | An xattr (`security.peios.sd`, or `system.ntfs_security` on NTFS volumes) on the inode. |
| Registry keys | Stored by the registry source. |
| Processes | A field on the PSB (process security block). |
| Tokens | A field on the token object itself. |
| Abstract Unix sockets | A field on the socket LSM blob, stamped from the binding thread's effective token at `bind()`. |
| IPC objects | A field on the object's kernel structure. |

The binary format — the **self-relative** layout — is uniform. An SD that was written for a file can be read by a tool that uses the same parser as for a registry key. This consistency is what lets system tools work generically across object types.

Note one important rule: **reading or writing the SD xattr directly is denied unconditionally**. Even with `CAP_DAC_OVERRIDE`. All SD access must go through the appropriate KACS API (`kacs_get_sd`, `kacs_set_sd`, or the equivalent registry-side APIs). The xattr exists so the SD travels with the file across normal filesystem operations (copy, backup, restore by tools that understand xattrs), but the security boundary is the API, not the xattr layer.

## The cost-of-modification asymmetry

A useful intuition: modifying different parts of an SD requires different rights.

| To change | You need |
|---|---|
| The DACL | `WRITE_DAC` on the object (the owner always has this unless suppressed by OWNER RIGHTS). |
| The owner | `WRITE_OWNER` on the object, *plus* a restriction on which SIDs you can name as the new owner (covered in [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md)). |
| The SACL | `ACCESS_SYSTEM_SECURITY` — granted by `SeSecurityPrivilege` rather than by the object's DACL. The DACL never grants this right. |

This asymmetry is deliberate. The DACL is what users control to manage their own data. Ownership is a stronger lever — taking ownership is a deliberate administrative act. The SACL is a system-level concern, gated by a privilege held by administrators and audit systems.

## How an SD gets created

There are three ways an object ends up with its SD:

1. **Inherited from a parent.** The most common path: when a child object is created, its SD is computed by combining the parent's inheritable ACEs with the creating principal's defaults. The result is stored on the child as a complete SD. The parent is not consulted again at access-check time. See [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).
2. **Explicitly supplied at creation.** The creator passes an SD as a parameter (to `kacs_open` with a create disposition, to a registry key create, etc.). This SD is taken as-is, subject to the rules about what fields the creator may set (the creator must be entitled to name the owner, for instance).
3. **Synthesised by the kernel.** For objects whose storage layer cannot hold an explicit SD (an abstract socket bound by a process that did not provide one, a file on a mount with `synthesize_ephemeral` policy and no parent inheritance), the kernel constructs a default SD from the creating principal's token (`default_dacl`, `owner_sid_index`, `primary_group_index`). The synthesis path is per-object-type but the result is a normal SD.

Once an SD is in place, it is the authoritative policy for the object. Modifying it after creation is a separate operation governed by `kacs_set_sd` and its access requirements.

## The SD as an evaluation target

When the access check runs on an SD, it is not reading every field. The pipeline does specific things with specific parts of the SD in a specific order. The full algorithm lives in [Access decisions](/peios/security-fundamentals/access-decisions/overview.md); a brief sketch for orientation:

1. The owner field is consulted to determine implicit rights (and whether they have been suppressed by OWNER RIGHTS).
2. The SACL is scanned for mandatory integrity labels and PIP trust labels — these gate the access independently of the DACL.
3. The DACL is walked, ACE by ACE, in order, applying first-writer-wins.
4. The SACL is scanned again for audit and alarm ACEs that should fire as a result of the access attempt.

Each step has its own page in this topic. The point of mentioning the order here is so that as you read the deeper pages you can see where each component is consulted.

## Where to start

If you want the structural details — what an ACL looks like, what each ACE type means, what bits the access mask uses — read [ACLs, ACEs, and access masks](/peios/security-fundamentals/security-descriptors/acls-and-aces.md).

If you want to understand how a DACL actually decides "allowed" or "denied" — first-writer-wins, ACE ordering, the canonical order, MAXIMUM_ALLOWED — read [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

If you want to know how owner implicit rights work and why OWNER RIGHTS can suppress them, read [Ownership and implicit rights](/peios/security-fundamentals/security-descriptors/ownership.md).

If you want the inheritance model — how a child's SD is computed from its parent and creator, and why parent changes don't propagate to existing children — read [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).

If you want conditional ACEs — the ABAC-style mechanism where access depends on token claims and resource attributes — read [Conditional ACEs](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

If you want the SACL specifically — audit, alarm, integrity labels, central access policy references, PIP trust labels — read [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md).

To read and change a descriptor from a shell — owner, DACL, SACL, label, inheritance — read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# ACLs, ACEs, and access masks

_Peios / Peios Security Fundamentals / Security Descriptors_

> The shared structure of DACLs and SACLs — ACL and ACE layout, the catalog of ACE types, the inheritance and audit flags, and the 32-bit access mask.

A DACL and a SACL are both **Access Control Lists** — sequences of **Access Control Entries** in a defined order. The two lists have different jobs (the DACL decides access; the SACL records system-level policy and audit), but they share a structure. This page covers that structure, plus the catalog of ACE types and the layout of the 32-bit access mask that almost every ACE carries.

## ACL structure

An ACL is, conceptually, an ordered array of ACEs. The binary form is a short header — a revision byte, the total `AclSize`, and the `AceCount` — followed by the ACEs back-to-back; the byte-level layout is owned by the [Security descriptors wire format](/peios/using-peios/wire-formats-reference/security-descriptors.md).

The maximum ACL size is 64 KB — the AclSize field is a 16-bit value. An ACL is self-delimiting: a parser walks exactly `AceCount` ACEs starting after the header and stops when it has consumed `AclSize` bytes.

The two ACL revisions differ only in what ACE types they are allowed to contain. `0x02` is the basic revision; `0x04` additionally allows object ACEs (the type with property GUIDs) and callback ACEs (the type with conditional expressions). Parsers should accept whichever revision they see; writers should emit the minimum revision necessary for the ACEs they are including.

## ACE structure

Every ACE has a 4-byte header — a one-byte `AceType`, a one-byte `AceFlags`, and a two-byte `AceSize` (always a multiple of 4) — followed by a type-specific body. The body's shape depends on the type. The most common shapes:

- **Single-SID body** — `Mask (4 bytes) | SID (variable)`. Used by basic allow/deny/audit/alarm ACEs and by the integrity/scoped-policy/trust-label SACL types.
- **Object ACE body** — `Mask (4) | Flags (4) | optional ObjectType GUID (16) | optional InheritedObjectType GUID (16) | SID (variable)`. The flags field controls whether each GUID is present.
- **Callback (conditional) body** — same as the corresponding non-callback type, plus a trailing ApplicationData block holding the conditional expression bytecode. See [Conditional ACEs](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

The detailed byte-level layouts live in the [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md). For this page, what matters is that every ACE is self-describing — the parser reads the header, dispatches by type, and consumes exactly `AceSize` bytes.

## The ACE type catalog

There are twenty-one ACE types, split into three families: access-control, audit/alarm, and system-policy. Most SDs you will look at use only a small subset of these — typically `ACCESS_ALLOWED`, `ACCESS_DENIED`, `SYSTEM_AUDIT`, and `SYSTEM_MANDATORY_LABEL`. The numeric type values are catalogued in [ACE types and flags](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md); the tables here describe what each type is for.

### Access-control ACE family

These ACEs participate in the DACL walk.

| Type | Body | Use |
|---|---|---|
| `ACCESS_ALLOWED` | Single SID | Grant the listed rights to the named principal. |
| `ACCESS_DENIED` | Single SID | Deny the listed rights to the named principal. |
| `ACCESS_ALLOWED_COMPOUND` | Reserved (not used in v0.20). | — |
| `ACCESS_ALLOWED_OBJECT` | Object ACE | Grant rights, scoped by property GUID or inherited only by certain child object types. |
| `ACCESS_DENIED_OBJECT` | Object ACE | Deny rights, same scoping. |
| `ACCESS_ALLOWED_CALLBACK` | Single SID + expression | Grant rights only if the conditional expression evaluates TRUE. |
| `ACCESS_DENIED_CALLBACK` | Single SID + expression | Deny rights when the expression evaluates TRUE or UNKNOWN. |
| `ACCESS_ALLOWED_CALLBACK_OBJECT` | Object ACE + expression | Grant rights, conditionally, scoped by GUID. |
| `ACCESS_DENIED_CALLBACK_OBJECT` | Object ACE + expression | Deny rights, conditionally, scoped by GUID. |

The most common ACEs you will write are `ACCESS_ALLOWED` and `ACCESS_DENIED` — plain "this principal gets these rights" or "this principal does not get these rights". The object variants matter when you are protecting directory-style objects with per-property permissions. The callback variants are how conditional expressions show up in ACEs.

### Audit and alarm ACE family

These ACEs sit in the SACL and decide when access produces an audit event.

| Type | Body | Use |
|---|---|---|
| `SYSTEM_AUDIT` | Single SID | Emit an audit event when the named principal attempts the listed rights. Flags decide whether success, failure, or both. |
| `SYSTEM_AUDIT_OBJECT` | Object ACE | Same, scoped by GUID. |
| `SYSTEM_AUDIT_CALLBACK` | Single SID + expression | Audit conditionally. UNKNOWN result emits the event (fail-open for auditing). |
| `SYSTEM_AUDIT_CALLBACK_OBJECT` | Object ACE + expression | Conditional, scoped by GUID. |
| `SYSTEM_ALARM` | Single SID | Configure per-operation continuous audit on the open handle. Each matching operation produces an event. |
| `SYSTEM_ALARM_OBJECT` | Object ACE | Same, scoped by GUID. |
| `SYSTEM_ALARM_CALLBACK` | Single SID + expression | Conditional continuous audit. |
| `SYSTEM_ALARM_CALLBACK_OBJECT` | Object ACE + expression | Conditional, scoped by GUID. |

The distinction between AUDIT and ALARM is important: AUDIT fires at the moment of handle creation (one event per access attempt), ALARM configures a per-operation mask on the open handle that fires on every subsequent operation. See [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md) and [Auditing](/peios/security-fundamentals/auditing/overview.md) for the full story.

### System-policy ACE family

These ACEs sit in the SACL and carry policy other than audit.

| Type | Body | Use |
|---|---|---|
| `SYSTEM_MANDATORY_LABEL` | Integrity SID + mask | The object's mandatory integrity label and the policy bits that govern who can write/read/execute up. |
| `SYSTEM_RESOURCE_ATTRIBUTE` | Everyone SID + claim entry | A name-value attribute on the object, referenceable as `@Resource.<name>` in conditional expressions. |
| `SYSTEM_SCOPED_POLICY_ID` | Policy SID | A reference to a central access policy that should be evaluated alongside the object's own DACL. |
| `SYSTEM_PROCESS_TRUST_LABEL` | PIP SID + mask | The object's PIP trust label and the explicit allowed mask for non-dominant callers. |

These four are how the SACL extends the access decision beyond what the DACL alone can express. Each has its own evaluation rules (covered in the relevant topics — MIC under [Access decisions](/peios/security-fundamentals/access-decisions/overview.md), resource attributes under [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md), CAAP under [Central access policies](/peios/security-fundamentals/central-access-policies/overview.md), PIP under [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md)).

## ACE flags

The `AceFlags` field is a bitmask. Most flags control inheritance — whether and how an ACE on a parent object propagates to children. Two flags control audit firing on SYSTEM_AUDIT and SYSTEM_ALARM ACEs. (Bit values: [ACE types and flags](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).)

| Flag | Effect |
|---|---|
| `OBJECT_INHERIT_ACE` | The ACE inherits to non-container child objects (files, not directories). |
| `CONTAINER_INHERIT_ACE` | The ACE inherits to container child objects (directories). |
| `NO_PROPAGATE_INHERIT_ACE` | The ACE is inherited by direct children but its `OBJECT_INHERIT_ACE` and `CONTAINER_INHERIT_ACE` flags are cleared in the inherited copy — it stops propagating after one level. |
| `INHERIT_ONLY_ACE` | The ACE is for inheritance only. The access check skips it; it exists only to be copied to children. |
| `INHERITED_ACE` | Set on ACEs that were created by inheritance, not by an explicit add. Tools use this flag to distinguish "this came from the parent" from "the user set this explicitly". |
| `SUCCESSFUL_ACCESS_ACE_FLAG` | (Audit/alarm only) Fire on successful access. |
| `FAILED_ACCESS_ACE_FLAG` | (Audit/alarm only) Fire on denied access. |

The inheritance flags compose in non-obvious ways. Combinations you will see often:

| Flags | Effect |
|---|---|
| `CONTAINER_INHERIT_ACE \| OBJECT_INHERIT_ACE` (`CI \| OI`) | Inherit to all descendants (files and directories), recursively. |
| `CONTAINER_INHERIT_ACE` (`CI`) | Inherit to descendant containers only. |
| `OBJECT_INHERIT_ACE` (`OI`) | Inherit to descendant non-containers only. |
| `CI \| OI \| INHERIT_ONLY_ACE` | Apply to descendants but not to this object. |
| `CI \| OI \| NO_PROPAGATE_INHERIT_ACE` | Apply to immediate children only, not grandchildren. |

The full inheritance machinery is on [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).

## The access mask

Every ACE that participates in the DACL walk, and every audit/alarm ACE, carries a 32-bit access mask. The mask uses four regions, partitioned by bit position.

| Region | Bits | Purpose |
|---|---|---|
| Object-specific rights | 0–15 | 16 bits whose meaning depends on the object type. For a file: `FILE_READ_DATA`, `FILE_WRITE_DATA`, etc. For a registry key: `KEY_QUERY_VALUE`, `KEY_SET_VALUE`, etc. For a process: `PROCESS_TERMINATE`, `PROCESS_VM_READ`, etc. |
| Standard rights | 16–20 | 5 bits shared across all object types: `DELETE`, `READ_CONTROL`, `WRITE_DAC`, `WRITE_OWNER`, `SYNCHRONIZE`. |
| Reserved | 21–23 | Must be zero. |
| Special rights | 24–25 | `ACCESS_SYSTEM_SECURITY` — gates SACL read/write. `MAXIMUM_ALLOWED` — a request flag, not a real right. |
| Reserved | 26–27 | Must be zero. |
| Generic rights | 28–31 | `GENERIC_ALL`, `GENERIC_EXECUTE`, `GENERIC_WRITE`, `GENERIC_READ`. Abstract — mapped to object-specific rights at evaluation time. |

The numeric values for every right live in [Access mask bits](/peios/using-peios/constants-and-catalogs/access-mask-bits.md); the mask's byte layout is in the [SD wire format](/peios/using-peios/wire-formats-reference/security-descriptors.md). A few things worth noting about the layout:

- **The same bit means different things on different object types.** Bit 0 is `FILE_READ_DATA` on a file but `KEY_QUERY_VALUE` on a registry key. The object-specific portion of the mask is reusable across types because the object manager knows what kind of object it is.
- **Standard rights are uniform.** `DELETE` always means delete the object; `READ_CONTROL` always means read the SD; `WRITE_DAC` always means modify the DACL; `WRITE_OWNER` always means change the owner. These bits work identically regardless of object type.
- **Generic rights are abstract.** `GENERIC_READ` does not name a specific right. It is a placeholder that gets mapped to a concrete combination of object-specific and standard rights at the moment of evaluation, using the object type's GenericMapping table.

### Generic mapping

When an access check sees a generic right in either an ACE mask or a requested mask, it consults the object type's GenericMapping table to substitute concrete rights. The mapping is per-type:

| Object type | GENERIC_READ maps to |
|---|---|
| File | `FILE_READ_DATA \| FILE_READ_ATTRIBUTES \| FILE_READ_EA \| READ_CONTROL \| SYNCHRONIZE` |
| Token | `TOKEN_QUERY \| READ_CONTROL` |
| Process | `PROCESS_QUERY_INFORMATION \| PROCESS_VM_READ \| READ_CONTROL` |

The full GenericMapping tables live in the [Constants and catalogs](/peios/using-peios/constants-and-catalogs/overview.md) reference. The key idea for now is: generic rights let an ACE author say "give the read permission for this thing" without knowing exactly which bits "read" means for this object type. The access check fills in the concrete bits at evaluation time.

This is also why an ACE that grants `GENERIC_READ` to a principal will produce different concrete grants depending on which object the ACE is on. The bit pattern in the ACE is the same; the meaning depends on where it lives.

### MAXIMUM_ALLOWED is a request, not a right

`MAXIMUM_ALLOWED` (bit 25) is special. It is not a right an ACE can grant — it is a marker the **caller** sets in a desired-access mask when asking "what is the maximum access I could be granted right now?". The access check sees the flag, evaluates the DACL fully, and returns every right the caller could be granted.

`MAXIMUM_ALLOWED` must not appear in an ACE. It is meaningless there.

## Sizes and limits

The size limits (maximum SD size, maximum ACL size, and the rest) are catalogued in [Other constants](/peios/using-peios/constants-and-catalogs/other-constants.md). A practical SD on a typical object is far below all of them — a few hundred bytes at most. The limits exist for the pathological cases.

## Where to go next

For how the ACEs in a DACL actually decide "allowed" or "denied" — first-writer-wins, ordering, the null/empty distinction — read [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

For the full numeric catalog of ACE types, flags, and access-mask bits, see [ACE types and flags](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).

To read and edit ACLs from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# DACL evaluation

_Peios / Peios Security Fundamentals / Security Descriptors_

> How a DACL decides — the first-writer-wins walk, canonical ACE order, the NULL-vs-empty DACL distinction, and what MAXIMUM_ALLOWED changes.

When the access check walks a DACL, it is answering one question, bit by bit: for each right the caller asked for, does this DACL grant or deny it? The mechanism is **first-writer-wins** — each bit is decided by the first ACE that mentions it, and once decided, no later ACE can override the decision. The walk is in the order ACEs appear in the DACL.

That rule is short. The consequences are not. The order of ACEs in a DACL is semantically load-bearing. A canonical order exists precisely so the rule produces the result you would expect. This page covers the walk, the canonical order, the special cases for null and empty DACLs, and what `MAXIMUM_ALLOWED` does to the algorithm.

## The walk

The access check starts with two state variables: `decided` (bits whose grant/deny status is now fixed) and `granted` (bits that have been granted). Both start empty. It walks the DACL from first ACE to last. For each ACE: skip if the ACE does not match the caller (wrong SID, INHERIT_ONLY_ACE set, conditional expression evaluates UNKNOWN-or-FALSE for allow / FALSE for deny). For an `ACCESS_DENIED` ACE that matches: any of its mask bits that are not yet in `decided` are added to `decided` and not added to `granted` (they are denied). For an `ACCESS_ALLOWED` ACE that matches: any of its mask bits that are not yet in `decided` are added to both `decided` and `granted`. After the walk, the access check has its answer: the bits in `granted` are the rights this DACL would give.

That paragraph is the whole algorithm for an ordinary DACL. Everything else on this page is either a special case (null/empty DACL, MAXIMUM_ALLOWED) or a rule about how to arrange ACEs so the walk produces the right answer.

## First-writer-wins, illustrated

Suppose a DACL contains:

1. `ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA`
2. `ACCESS_DENIED Alice FILE_WRITE_DATA`

Alice asks for `FILE_READ_DATA | FILE_WRITE_DATA`. The walk:

- ACE 1 matches Alice. `FILE_READ_DATA` and `FILE_WRITE_DATA` are not yet decided. Both are added to `decided` and to `granted`.
- ACE 2 matches Alice. `FILE_WRITE_DATA` is already decided. The ACE has no effect.

Result: Alice gets both rights. The deny in ACE 2 came too late.

Now suppose the DACL is in the other order:

1. `ACCESS_DENIED Alice FILE_WRITE_DATA`
2. `ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA`

The walk:

- ACE 1 matches Alice. `FILE_WRITE_DATA` is not yet decided. It is added to `decided` but not to `granted` (denied).
- ACE 2 matches Alice. `FILE_READ_DATA` is not yet decided — it is added to both. `FILE_WRITE_DATA` is already decided. The ACE only takes effect on `FILE_READ_DATA`.

Result: Alice gets `FILE_READ_DATA` only. The deny in ACE 1 prevented the allow in ACE 2 from granting write.

The two DACLs contain exactly the same ACEs but produce different results. That is what is meant by "order matters".

## The canonical ACE order

Because ordering is load-bearing, Peios defines a **canonical order** that produces the result a reasonable reader would expect: explicit denies override explicit allows, and explicit ACEs override inherited ACEs.

| Position | ACE class |
|---|---|
| 1 | Explicit deny ACEs (set directly on the object). |
| 2 | Explicit allow ACEs. |
| 3 | Inherited deny ACEs. |
| 4 | Inherited allow ACEs. |

A DACL written in this order has the following properties:

- An explicit deny always overrides any allow (explicit or inherited) for the bits it covers.
- An explicit allow overrides any inherited rule.
- Inherited denies override inherited allows.

Tools that compose DACLs — a properties dialog, a system utility — are expected to maintain canonical order. The access check itself does not require it; the walk is the same regardless. But a DACL whose ACEs are out of canonical order produces results that may surprise the author. "I added a deny ACE and it had no effect" is almost always a canonical-order problem: the deny was placed after an allow that already decided the bits.

The four classes are distinguished by ACE type plus the `INHERITED_ACE` flag (0x10). Explicit ACEs have the flag clear; inherited ACEs have it set. The kernel sets the flag automatically when an ACE is created by inheritance.

## NULL DACL vs empty DACL

The two states look identical to a casual reader but mean opposite things.

| State | What the SD looks like | What the access check does |
|---|---|---|
| **NULL DACL** | `SE_DACL_PRESENT` flag is **not set** in the SD's control flags. | Grants every valid right. The walk is skipped. |
| **Empty DACL** | `SE_DACL_PRESENT` is set, the DACL exists, but contains zero ACEs. | Grants no rights (except owner implicit rights — see [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md)). The walk happens, decides nothing, and the result is an empty `granted` mask. |

> [!WARNING]
> The NULL case is rare and dangerous. It is how you say "this object has no discretionary access control at all — anyone can do anything". You will see it on a few system objects where no DACL would be the right thing. You will never see it on a file you set permissions on.

The empty-DACL case is what you get when you explicitly remove every ACE from a DACL but keep the DACL itself. The intent is "no one has any rights to this object", and the access check honours it.

**Tools that print or parse SDs must distinguish the two.** Saying "the DACL is empty" without distinguishing between absent-DACL and zero-ACE-DACL is a recipe for misreading an object's policy.

## ACE flags that affect the walk

A few flags change whether or how an ACE participates:

| Flag | Effect on the walk |
|---|---|
| `INHERIT_ONLY_ACE` (0x08) | The ACE is **skipped** entirely during the DACL walk. It exists only to be copied to children at inheritance time. |
| `INHERITED_ACE` (0x10) | The ACE participates normally. The flag is used to identify inherited ACEs for canonical-ordering purposes, not to change evaluation. |
| `OBJECT_INHERIT_ACE`, `CONTAINER_INHERIT_ACE`, `NO_PROPAGATE_INHERIT_ACE` | No effect on the walk. These flags control inheritance to child objects; the walk on this object ignores them. |
| `SUCCESSFUL_ACCESS_ACE_FLAG`, `FAILED_ACCESS_ACE_FLAG` | These are audit/alarm flags and have no effect on access-control ACEs. |

The one to remember is `INHERIT_ONLY_ACE`. An ACE with that flag set is for inheritance purposes only and does not control access to the object it is on. This is sometimes the source of a confusing "but my ACE is right there in the DACL" — yes, it is, but it is inherit-only, so the walk skips it.

## MAXIMUM_ALLOWED: the "what could I get" mode

Normally the access check stops as soon as every bit in the requested mask is decided. If the caller asked for `FILE_READ_DATA | FILE_WRITE_DATA` and both bits are decided after three ACEs in a DACL of twenty, the walk stops — there is nothing more to learn.

When the caller sets `MAXIMUM_ALLOWED` (bit 25) in the requested mask, the access check changes mode. Instead of stopping when the requested bits are decided, it walks the entire DACL and accumulates every bit that any ACE would grant the caller. The returned `granted` mask is the union of all rights the caller could have gotten.

This is what tools use to display "you have read, write, and delete access to this file" without having to ask for each right separately. It is also what services use when they want to open an object with whatever rights they can get, deferring the actual privilege check to later operations.

Two specific rules about MAXIMUM_ALLOWED:

- The flag itself is **stripped** from the requested mask before the walk runs. The actual `decided`/`granted` arithmetic is done on the real bits.
- The granted mask is returned through the same channel as a normal access check; the caller learns what they got back.

`MAXIMUM_ALLOWED` does not affect first-writer-wins. A deny ACE that decides a bit still decides it. The full-walk mode just means the walk does not exit early.

## What the DACL walk does not decide

A handful of access decisions happen outside the DACL walk:

- **Owner implicit rights** are granted before the walk starts, based on the SD's owner field. Covered in [Ownership and implicit rights](/peios/security-fundamentals/security-descriptors/ownership.md).
- **Privilege-granted rights** (SeBackup, SeRestore, SeSecurity, SeTakeOwnership, SeRelabel) are decided before or after the walk, depending on the privilege. The DACL has no say.
- **MIC** (mandatory integrity) and **PIP** (process integrity protection) checks happen before the DACL walk for non-dominant callers, and can pre-decide write or other bits as denied.
- **Restricted token** and **confinement** intersections happen after the DACL walk and can narrow the result.
- **CAAP** (central access policies) evaluate alongside the DACL and can further restrict.
- **SACL audit** happens after the access decision, based on whatever the DACL walk + the other layers produced.

In other words, the DACL walk is one layer in a longer pipeline. It is the layer most people mean when they talk about "permissions", but it is not the whole story. The full pipeline lives in [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

## Practical tips

A handful of patterns that come up repeatedly:

- **When a deny ACE seems to have no effect**, check whether it appears after an allow ACE that already decided the bit. The fix is to move it earlier (or move the allow later) — i.e. canonicalise.
- **When an inherited ACE seems to override an explicit one**, check canonical order. Inherited ACEs should sit after explicit ones; if you find an inherited deny ahead of an explicit allow, the DACL is out of order.
- **When testing access**, query the full SD and walk it manually if the answer is surprising. Almost every "why doesn't this work" reduces to either a misordered DACL, a misplaced INHERIT_ONLY_ACE, or a missing flag.
- **When writing a DACL programmatically**, build it in canonical order from the start. Adding ACEs at the end and "sorting later" is fragile.

## Where to go next

For the implicit rights the owner gets before the walk starts — and how to suppress them — read [Ownership and implicit rights](/peios/security-fundamentals/security-descriptors/ownership.md).

For the full pipeline the walk sits inside — MIC, PIP, privileges, narrowing layers — read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

To test what a DACL would grant from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Ownership and implicit rights

_Peios / Peios Security Fundamentals / Security Descriptors_

> The owner's implicit READ_CONTROL and WRITE_DAC — the you-cannot-lock-yourself-out guarantee — how OWNER RIGHTS suppresses them, and changing ownership.

Every security descriptor names an owner — a single SID identifying the principal responsible for the object's access policy. The owner has authority over the object's DACL: they can change it, including granting or revoking access for themselves and others.

That authority is implemented by two implicit rights granted outside the DACL walk: `READ_CONTROL` (read the SD) and `WRITE_DAC` (modify the DACL). The owner gets these regardless of what the DACL says. An owner cannot accidentally write a DACL that locks themselves out of their own object — the implicit rights are the floor.

This is the "you cannot lock yourself out" guarantee. It is also why every SD must have an owner: an object with no owner has no fallback authority, and the access check rejects an SD without one.

## The implicit rights

When the access check starts evaluating a DACL, it first checks whether the caller's token represents the SD's owner. If it does, the caller is granted `READ_CONTROL` and `WRITE_DAC` before the DACL walk begins. Both bits go into the `granted` mask and into `decided`, so no later ACE — allow or deny — can change them.

"Represents the owner" means the caller's `user_sid` matches the SD's owner SID **or** the caller's token has the owner SID as a group with the `SE_GROUP_OWNER` flag set. The flag is on the group, not on the token globally; only groups specifically marked as eligible to act as owner count. This lets a token represent multiple potential owners (the user themselves, plus the administrative group they belong to that owns shared objects).

The implicit grant is just `READ_CONTROL` and `WRITE_DAC`. Nothing else. The owner of a file does not automatically get `FILE_READ_DATA` or `FILE_WRITE_DATA` — those rights come from the DACL like any other. An owner who has not written themselves into their own DACL can read and modify the policy but cannot read or write the data without first adding an allow ACE.

This split is what makes the model work. The owner controls policy; the DACL controls data access. An owner can lose all data-access rights to their own object (perhaps because every ACE was a deny against them) and still recover, because `WRITE_DAC` is implicit and lets them rewrite the DACL.

## OWNER RIGHTS: suppressing the implicit grant

Sometimes you want an object where the owner is not allowed to be a backdoor. A sensitive log file owned by a service should not let the service rewrite its own audit policy. A configuration object owned by a user should not let that user override administrator-set restrictions.

The mechanism is the well-known SID **`S-1-3-4`** — `OWNER RIGHTS`. When any ACE in the DACL names `OWNER RIGHTS`, the kernel suppresses the implicit grant of `READ_CONTROL | WRITE_DAC`. The owner gets whatever the ACE says they get, no more.

The rule is more precise than that. Suppression happens when:

- An ACE in the DACL names `S-1-3-4` as its SID, and
- The ACE has `INHERIT_ONLY_ACE` clear (it is a real, evaluated ACE, not an inherit-only placeholder).

If any such ACE is present, the owner's implicit rights are suppressed regardless of what the ACE grants. An OWNER RIGHTS allow ACE granting only `FILE_READ_DATA` means the owner can read but not write the DACL — they have lost `WRITE_DAC` entirely.

During the DACL walk, the OWNER RIGHTS SID matches the caller if they are the owner. So an `ACCESS_ALLOWED` ACE on `S-1-3-4` grants those rights to whoever happens to own the object. The ACE composes with the rest of the DACL like any other ACE; first-writer-wins still applies.

The two pieces — suppress implicit, then evaluate normally — let you write DACLs that say:

- "The owner gets exactly read and read-attributes, nothing else, including not WRITE_DAC."
- "The owner has the same rights as any other authenticated user; no special privileges."
- "The owner has no access at all; only specific named principals can touch this object."

> [!WARNING]
> These constructions are not always wise. Locking the owner out of `WRITE_DAC` means no one short of an administrator with `SeTakeOwnership` can change the DACL. That can be exactly what you want, but it is also how an object becomes administratively unrecoverable. Use sparingly.

## Changing ownership

Ownership is itself a property the access check protects. To change an SD's owner, the caller must hold `WRITE_OWNER` on the object. By default, that right is in the standard rights section of the access mask (bit 19, value `0x00080000`), and a DACL can grant it like any other right.

Once a caller has `WRITE_OWNER`, they cannot just name any SID as the new owner. The kernel applies one of two rules, depending on the caller's privileges.

**Without `SeRestorePrivilege`**, the new owner SID must be either:

- The caller's own `user_sid`, or
- A SID present in the caller's `groups` list with the `SE_GROUP_OWNER` flag set.

This restriction is what stops a user with `WRITE_OWNER` from "transferring" an object to someone else and washing their hands of it. You can take ownership yourself (assuming you have `WRITE_OWNER` or `SeTakeOwnershipPrivilege`), but you cannot hand the object to an arbitrary third party.

**With `SeRestorePrivilege`**, the restriction is bypassed. The new owner can be any well-formed SID. This is what backup and restore tools use to restore an object whose original owner is not present on the current system.

The kernel's `kacs_set_sd` enforces both rules at the time of the change — there is no "set owner to anyone, validate later" path.

## SeTakeOwnership: an unconditional grant

`SeTakeOwnershipPrivilege` is a privilege that grants `WRITE_OWNER` on any object, regardless of the DACL. A caller holding the privilege can take ownership of anything they can reach — a file whose DACL grants them nothing, a registry key with no allow ACE for them at all.

The privilege does **not** bypass:

- The "new owner must be self or a SE_GROUP_OWNER group" rule. The caller still has to name themselves (or one of their owner-eligible groups) as the new owner. To name a different principal as the new owner, the caller additionally needs `SeRestorePrivilege`.
- MIC. A lower-integrity caller cannot take ownership of a higher-integrity object even with the privilege.
- PIP. A non-dominant caller cannot take ownership of an object protected by a higher PIP trust label even with the privilege.

`SeTakeOwnership` is the lever that makes administrative recovery possible. An object whose DACL has accidentally locked everyone out can still be reached by an administrator with the privilege, who can take ownership, then use the implicit `WRITE_DAC` to rewrite the DACL. Without the privilege, such an object would be permanently inaccessible.

The privilege is granted very narrowly — typically only to BUILTIN\Administrators and the SYSTEM account. Most service accounts and ordinary users do not have it.

## The primary group

The SD's primary group field is a relic. It exists because the SD format reserves a slot for it and because some legacy code paths consult it, but in Peios its role is almost entirely passive.

The one place the primary group matters is **inheritance**. When the kernel synthesises a new child SD and an inheritable ACE names `CREATOR_GROUP` (the well-known SID `S-1-3-1`), the substitution uses the new child's primary group SID. The primary group is set to the creator's token's primary group at the time of creation.

After inheritance has run, the primary group on the child SD is just a stored value. The access check does not consult it. ACEs do not match it. It exists so the `CREATOR_GROUP` substitution has a target.

You will rarely need to think about the primary group. The few times it matters are:

- Programs that read SDs and expect to display the primary group alongside the owner.
- Legacy POSIX-style applications that map the primary group to a Linux GID for compatibility purposes.
- Inheritable ACEs that use `CREATOR_GROUP` to grant access to "the group of whoever created this".

Most of the time, the primary group is set to whatever the creating token had and is then ignored.

## Recovery patterns

A few patterns for when ownership is the lever you need:

- **Lost access to your own file.** You wrote a DACL that denies you. You are still the owner; implicit `WRITE_DAC` lets you rewrite the DACL and add back what you need.
- **Inherited a mess of objects from a departed user.** Their tokens are gone, but their SIDs are still in the DACLs. An administrator with `SeTakeOwnership` can take ownership of each object (subject to the same-self-or-owner-group rule for the new owner) and then rewrite the DACLs.
- **Restoring backups.** A backup tool with `SeRestorePrivilege` can set arbitrary owners during restore, reproducing the original SDs even when the original principals are not present on the current system.
- **Locking the owner out deliberately.** Use an `OWNER RIGHTS` ACE — explicitly grant the owner what you want them to have (perhaps nothing) and the implicit grant of `READ_CONTROL | WRITE_DAC` will be suppressed. Use with care; this is what makes objects administratively unrecoverable except via `SeTakeOwnership`.

## Where to go next

For how the `CREATOR_OWNER` and `CREATOR_GROUP` placeholders get substituted when children are created, read [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).

For how `SeTakeOwnership` and `SeRestore` fit the wider privilege model, read [Privileges](/peios/security-fundamentals/privileges/overview.md).

To view and change an object's owner from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Inheritance

_Peios / Peios Security Fundamentals / Security Descriptors_

> A child's SD is computed once, at creation, from the parent's inheritable ACEs and the creator's defaults — the parent is never walked at access-check time.

When a new object is created under a parent — a file in a directory, a registry key under another key — the new object gets its own security descriptor. The kernel computes that SD at creation time by combining inheritable ACEs from the parent's SD with whatever the creator supplied or defaulted. The result is stored on the child as a complete, self-contained SD. From that point on, the access check on the child consults only the child's own SD; the parent is not walked.

This is the **eager evaluation** model. It has one large consequence: modifying a parent's inheritable ACEs does not propagate to existing children. The children's SDs were computed when they were created and are now their own. Whatever changes you make to the parent affect only objects created after the change. Children that already exist keep the SDs they had.

This page covers the inheritance algorithm — what counts as inheritable, how the merge works, the special placeholders (`CREATOR_OWNER`, `CREATOR_GROUP`), and the `SE_DACL_PROTECTED` and `SE_SACL_PROTECTED` flags that opt an object out of inheritance entirely.

## Eager vs lazy

Two models are possible:

- **Lazy inheritance**: a child has no inherited ACEs of its own; access checks on the child walk the parent SD too. Changes to the parent propagate immediately.
- **Eager inheritance**: a child's SD is fully computed at creation; access checks consult only the child. Changes to the parent affect future children only.

Peios uses eager inheritance, like the access-control model it grew out of. The advantages: access checks are local (you only need one SD's worth of data), no walking up directory trees during evaluation, no concurrency issues with "the parent moved between my permission check and the operation". The disadvantage: when you change a parent's permissions, you need a separate sweep to propagate the change to existing children — a tool's job, not the kernel's.

In practice, eager inheritance means: when an administrator changes a directory's DACL and wants the change to apply to existing files, they need a propagation tool. The kernel does not do this automatically and provides no syscall for "re-inherit all children". A propagation tool walks the tree and rewrites child SDs explicitly.

## The inheritance flags

Four ACE flags control how an ACE propagates to children. They are the same four mentioned on the [ACLs and ACEs](/peios/security-fundamentals/security-descriptors/acls-and-aces.md) page:

| Flag | Effect at creation of a child |
|---|---|
| `OBJECT_INHERIT_ACE` (`OI`, 0x01) | The ACE inherits to children that are **non-containers** (files, not directories). |
| `CONTAINER_INHERIT_ACE` (`CI`, 0x02) | The ACE inherits to children that are **containers** (directories). |
| `NO_PROPAGATE_INHERIT_ACE` (`NP`, 0x04) | The ACE inherits to direct children but its `OI`/`CI` flags are **cleared** in the inherited copy, so it stops propagating after one level. |
| `INHERIT_ONLY_ACE` (`IO`, 0x08) | The ACE is **not evaluated** on the object it sits on. It exists only to be inherited. |

Common combinations:

| Flags | Meaning |
|---|---|
| `CI \| OI` | Inherit to all descendants, files and directories, recursively. The most common pattern. |
| `CI` | Inherit to descendant directories only. |
| `OI` | Inherit to descendant files only. |
| `CI \| OI \| IO` | Apply to descendants but not to this object. |
| `CI \| OI \| NP` | Apply to immediate children only. Grandchildren do not inherit. |
| `(none)` | This ACE does not inherit. It applies only to this object. |

The `INHERIT_ONLY_ACE` (`IO`) flag is what lets you say "this rule is for children, not for the parent itself". An `IO` ACE is invisible to the access check on its own object; the DACL walk skips it. But it is copied to children at creation time.

## The merge algorithm

When a new child is created, the kernel needs to produce the child's SD from three inputs:

1. The **parent's** SD. Inheritable ACEs from here will be copied (with adjustments) into the child.
2. The **creator's** SD, if one was supplied as a parameter to the create call. This contains explicit ACEs the creator wants on the new object.
3. The **creator's token**. Provides defaults (owner, primary group, default DACL) when the creator did not supply specific values.

The merge proceeds as follows:

1. **Compute the child's owner.** If the creator supplied an owner in the explicit SD, use it (subject to the same-self-or-owner-group rule from [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md)). Otherwise, use the creator's token's default owner.
2. **Compute the child's primary group.** Same rule: explicit SD if present, otherwise the creator's token's default primary group.
3. **Compute the child's DACL.** Start with explicit ACEs from the creator's SD (or, if the creator did not supply a DACL, the creator's token's `default_dacl`). Then append inheritable ACEs from the parent's DACL — but only those whose flags say they should propagate to this kind of child (container or non-container).
4. **Compute the child's SACL.** Same algorithm as the DACL, applied to SACL ACEs.

Each inherited ACE goes through a small transformation as it is copied:

- The `INHERITED_ACE` flag (0x10) is **set** in the copy. This marks it as having come from inheritance, not from explicit assignment.
- If `NO_PROPAGATE_INHERIT_ACE` was set, the `OI` and `CI` flags are **cleared** in the copy. (NP itself is also typically cleared.)
- If the ACE's SID is `CREATOR_OWNER` (`S-1-3-0`), the SID is **substituted** with the new child's owner SID.
- If the ACE's SID is `CREATOR_GROUP` (`S-1-3-1`), the SID is **substituted** with the new child's primary group SID.

The CREATOR substitution is the mechanism that makes inheritable ACEs portable. A directory's DACL containing `CI | OI ACCESS_ALLOWED CREATOR_OWNER GENERIC_ALL` means "every file or subdirectory created under here grants full access to whoever owns it". The owner of each child is different; the substitution at creation time fills in the right SID for each one.

## Sources of an inherited SD, ranked

Which source wins for each component of the child SD, in order:

| Component | First source | Second source | Third source |
|---|---|---|---|
| Owner | Creator's explicit SD (if owner present) | Creator's token's default owner | (none — must be present) |
| Primary group | Creator's explicit SD (if group present) | Creator's token's default primary group | (none — must be present) |
| DACL | Creator's explicit SD (if DACL present) | Creator's token's `default_dacl` | (no DACL means NULL DACL) |
| SACL | Creator's explicit SD (if SACL present) | (no fallback) | (no SACL means no audit policy) |

Plus, in every case, **inheritable ACEs from the parent are appended** to whatever DACL/SACL was chosen as the base. The parent's contributions never replace; they always extend.

This means: a creator who supplies an explicit DACL gets that DACL plus the inheritable ACEs from the parent. They cannot exclude the parent's ACEs except by setting the protected flag (see below).

## Protected ACLs

There is an opt-out: the `SE_DACL_PROTECTED` and `SE_SACL_PROTECTED` flags in the SD's control field.

| Flag | Effect |
|---|---|
| `SE_DACL_PROTECTED` (0x1000) | The child's DACL does not accept inheritable ACEs from the parent. Only the explicit ACEs in the creator's SD (or the creator's `default_dacl`) appear. |
| `SE_SACL_PROTECTED` (0x2000) | Same, for the SACL. |

When a creator sets these flags on the explicit SD they pass to the create call, the resulting child's DACL/SACL is purely what the creator wrote. The parent might have a hundred inheritable ACEs; none of them appear on the child.

Protected ACLs are used when an object should be administered without reference to its container. A service that creates files whose access should be controlled only by the service, not by whoever happens to own the directory it lives in, sets `SE_DACL_PROTECTED` on each file's explicit SD at creation.

The flags can also be set after creation, via a subsequent `kacs_set_sd`. Doing so does not retroactively remove previously inherited ACEs — they were copied at creation time and are now the child's own — but it does protect against future re-inheritance if a propagation tool sweeps the tree.

## Auto-inherit flags

Two more SD control flags work alongside the protected flags:

| Flag | Effect |
|---|---|
| `SE_DACL_AUTO_INHERIT_REQ` (0x0100) | Set by a creator that wants the child to participate in auto-inheritance. Tools that propagate inherited ACEs use this to decide whether to update the child. |
| `SE_DACL_AUTO_INHERITED` (0x0400) | Set by the kernel on a child whose inherited ACEs have been computed by the auto-inheritance algorithm. |

The corresponding SACL pair (`SE_SACL_AUTO_INHERIT_REQ`, `SE_SACL_AUTO_INHERITED`) works the same way.

These flags exist so tools can distinguish "this child wants automated propagation" from "this child has its own bespoke DACL the user wrote by hand". A propagation tool sweeping a tree typically updates only children with the auto-inherit flags set, leaving hand-crafted DACLs untouched.

## What inheritance does not do

A handful of things often attributed to inheritance but not done by it:

- **Inheritance does not happen at access-check time.** Once a child has its SD, the parent is not consulted on access decisions. Inheritance is purely a creation-time activity.
- **Inheritance does not retroactively update children.** A change to a parent's inheritable ACEs is invisible to children created before the change. Propagation requires an explicit sweep.
- **Inheritance does not chain across moves.** Moving a file from directory A to directory B does not re-run inheritance against B. The file's SD remains whatever it was. (Copying is a different operation; it creates a new object, which does go through inheritance.)
- **Inheritance does not preserve ACE order.** Inheritable ACEs from the parent are appended to the explicit ACEs from the creator. If you want canonical order, the creator needs to either start from the parent's order and intersperse, or run a canonicalisation pass afterwards.

## Inheritance and creator-supplied SDs

A subtle interaction worth knowing: when a creator passes an explicit SD to the create call, the explicit ACEs go before the inherited ones. This means the creator's ACEs sit higher in the DACL than the parent's, and first-writer-wins favours them.

If the parent says "deny X" and the creator says "allow X", the creator's allow wins because it sits first. If the canonical order says the parent's inherited deny should win (because explicit ACEs from the creator should not override the parent's policy), the creator needs to either not supply a competing ACE or arrange the explicit SD to use the protected-ACL flag.

This is one of the reasons creators usually pass **no** explicit DACL (relying on the default-DACL + parent-inheritance merge) rather than constructing a custom SD. The latter is correct in cases where the creator knows exactly what the child's policy should be; the former is correct in cases where the creator just wants the natural policy of its location.

## Where to go next

For ACEs whose effect is gated by an expression rather than fixed at write time, read [Conditional ACEs](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

To sweep a tree and propagate inheritable ACEs from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Conditional ACEs

_Peios / Peios Security Fundamentals / Security Descriptors_

> An ACE gated by an expression over token claims, resource attributes, and local context — the model, the expression language, and the three-valued logic.

A **conditional ACE** is an ACE whose effect depends on a boolean expression. Where an ordinary allow ACE says "grant these rights to this principal", a conditional allow ACE says "grant these rights to this principal if this expression is true". The expression can reference attributes of the calling token (the user's department, the device's compliance state), attributes of the object (its classification, its sensitivity), and runtime context supplied by the caller.

Conditional ACEs are how Peios expresses attribute-based access control — ABAC — as opposed to identity-based access control where every rule names a specific principal or group. The conditional model is strictly an extension: every conditional ACE is also keyed to a SID, so it composes naturally with the rest of the DACL. The expression is an extra gate on top.

This page covers the model, the expression language at a conceptual level, and the three-valued logic that makes the model fail safely when an expression cannot be evaluated.

## The model

A conditional ACE is one of the callback ACE types from [ACLs and ACEs](/peios/security-fundamentals/security-descriptors/acls-and-aces.md):

| Type | Behaviour |
|---|---|
| `ACCESS_ALLOWED_CALLBACK` | Allow ACE, gated by expression. |
| `ACCESS_DENIED_CALLBACK` | Deny ACE, gated by expression. |
| `ACCESS_ALLOWED_CALLBACK_OBJECT` | Allow ACE, gated by expression, scoped by GUID. |
| `ACCESS_DENIED_CALLBACK_OBJECT` | Deny ACE, gated by expression, scoped by GUID. |
| `SYSTEM_AUDIT_CALLBACK` | Audit ACE, gated by expression. |
| `SYSTEM_AUDIT_CALLBACK_OBJECT` | Audit ACE, gated by expression, scoped by GUID. |
| `SYSTEM_ALARM_CALLBACK` | Alarm ACE, gated by expression. |
| `SYSTEM_ALARM_CALLBACK_OBJECT` | Alarm ACE, gated by expression, scoped by GUID. |

The body of a callback ACE is the body of the corresponding non-callback ACE — Mask, optional GUIDs, SID — plus a trailing **ApplicationData** block holding the conditional expression in a binary bytecode format. The bytecode begins with the four-byte magic `0x61 0x72 0x74 0x78` (the ASCII string `artx`); a callback ACE whose ApplicationData does not start with that magic is treated as having an UNKNOWN expression and falls into the UNKNOWN-handling rules below.

During the DACL walk, the access check evaluates the expression at the moment the ACE is examined. The result is TRUE, FALSE, or UNKNOWN. What happens next depends on the ACE type and the result:

| ACE class | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| Allow callback | ACE applies (grants its rights) | ACE is skipped | ACE is skipped |
| Deny callback | ACE applies (denies its rights) | ACE is skipped | ACE applies (denies) |
| Audit callback | Emit event | Skip | Emit event |
| Alarm callback | Configure continuous-audit mask | Skip | Configure mask |

The asymmetry between allows and denies is deliberate. An UNKNOWN result on an allow is skipped (the allow does not get to grant access on incomplete information). The same UNKNOWN on a deny does apply (the deny errs on the side of denying). And UNKNOWN on audit fires the event (the audit errs on the side of recording).

The principle is that UNKNOWN never accidentally grants. It can only fail safely closed, or fail safely open for auditing.

## The four attribute namespaces

Conditional expressions reference attributes through four namespaces, each backed by a different source:

| Namespace | Source | Set by |
|---|---|---|
| `@User.<name>` | The caller's token's `user_claims` field. | authd at token creation, from the user's directory object. |
| `@Device.<name>` | The caller's token's `device_claims` field. | authd at token creation, from the machine's directory object. |
| `@Resource.<name>` | A resource attribute ACE in the object's SACL. | The object's administrator, via `kacs_set_sd`. |
| `@Local.<name>` | A per-access-check parameter supplied by the caller. | Whatever code is making the access check. |

So an expression like `@User.Department == "Engineering"` refers to a claim on the caller's token. `@Resource.Classification == "Public"` refers to an attribute on the object being accessed. `@Local.Time > 9` refers to context the calling code passed in. The four namespaces let an ACE reference the caller, the object, and the runtime situation in one expression.

Two of the namespaces are token-side (caller properties), one is object-side (resource properties), and one is per-call context. They cover the three "where can an attribute come from?" possibilities cleanly.

User and device claims are covered on [Claims on a token](/peios/security-fundamentals/identity/claims.md). Resource attributes are covered on [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md). Local claims are passed by the caller; the API surface lives in the [Kernel ABI reference](/peios/using-peios/kernel-abi-reference/overview.md).

## The expression language

The expression bytecode is **postfix** (reverse Polish notation), evaluated by a stack machine. Literal tokens push values onto the stack; operator tokens pop operands, do their work, and push a result. The final stack must contain exactly one tri-state value, which is the expression's result.

On the wire an expression is bytecode; the textual form, which [`sd --if`](/peios/security-fundamentals/security-descriptors/sd-command.md) accepts, looks like:

```
@User.Department == "Engineering"

@User.Department == "Engineering" && @Resource.Classification != "Secret"

Member_of({SID-of-AdminGroup})

@Device.Compliance == "Compliant" && @Local.Time >= 9 && @Local.Time <= 17

Exists(@User.ProjectAccess) && @User.ProjectAccess Any_of {"alpha", "beta"}
```

The operators fall into four families:

| Family | Operators |
|---|---|
| Relational | `==`, `!=`, `<`, `<=`, `>`, `>=` |
| Set / membership | `Contains`, `Exists`, `Any_of`, `Member_of`, `Device_Member_of`, `Member_of_Any`, `Device_Member_of_Any`, plus `Not_*` variants |
| Logical | `&&`, `||`, `!` |
| Attribute references | `@User.`, `@Device.`, `@Resource.`, `@Local.` |

The membership operators reference SID sets — `Member_of({S-1-5-21-...})` evaluates TRUE if the caller's token has that SID in its groups. `Any_of` evaluates TRUE if any value in a multi-valued attribute appears in a literal set. `Contains` and `Exists` test for presence rather than equality.

The full operator catalog with bytecode values lives in [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md).

### Value types and comparisons

Expressions operate on six value types: `INT64`, `UINT64`, `STRING`, `SID`, `BOOLEAN`, `OCTET`. Comparisons follow rules you would expect:

- **Numeric comparisons** work across `INT64` and `UINT64` with sign-aware promotion: a negative `INT64` is always less than any `UINT64`.
- **String comparisons** are case-insensitive by default. A claim or attribute can opt into case sensitivity via the `CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE` flag (see [Claims on a token](/peios/security-fundamentals/identity/claims.md)).
- **SID comparisons** are byte-exact, same as everywhere else.
- **Octet comparisons** are byte-exact.

Boolean coercion of other types happens when an expression treats a non-boolean as a boolean (rarely, but it can happen): a nonzero integer is TRUE, zero is FALSE, NULL is UNKNOWN, SIDs/octets/composites are UNKNOWN. Compositions of types that do not coerce cleanly produce UNKNOWN.

### The "missing attribute" case

The most common UNKNOWN producer is **a reference to an attribute the token or object does not carry**. `@User.ClearanceLevel` on a token that has no `ClearanceLevel` claim evaluates to UNKNOWN — not to NULL, not to FALSE, not to an empty string. UNKNOWN.

This is why the three-valued logic exists. Schemas evolve; not every user object has every defined attribute; the access check needs to behave well when an attribute is missing. The rule "UNKNOWN skips an allow, applies a deny, fires an audit" means a missing attribute can never accidentally upgrade access — at worst, the gated allow does nothing.

## Three-valued logic

The truth tables for `&&`, `||`, and `!` over `{TRUE, FALSE, UNKNOWN}`:

### AND (`&&`)

| | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| **TRUE** | TRUE | FALSE | UNKNOWN |
| **FALSE** | FALSE | FALSE | FALSE |
| **UNKNOWN** | UNKNOWN | FALSE | UNKNOWN |

### OR (`||`)

| | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| **TRUE** | TRUE | TRUE | TRUE |
| **FALSE** | TRUE | FALSE | UNKNOWN |
| **UNKNOWN** | TRUE | UNKNOWN | UNKNOWN |

### NOT (`!`)

| Input | Output |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |

The pattern: a definite value (TRUE or FALSE) combined with UNKNOWN can sometimes determine the result (FALSE `&&` UNKNOWN is FALSE; TRUE `||` UNKNOWN is TRUE) and sometimes cannot (TRUE `&&` UNKNOWN is UNKNOWN; FALSE `||` UNKNOWN is UNKNOWN). NOT preserves UNKNOWN.

This means **you cannot prove an UNKNOWN attribute is false by negating it**. `! Exists(@User.ClearanceLevel)` evaluates to UNKNOWN if the attribute is missing, because `Exists` returns UNKNOWN in that case and the NOT preserves it. The right way to check "the user does not have this attribute" is the `Not_Exists` operator, which returns TRUE for a missing attribute and FALSE for a present one.

## A worked example

Consider an SD on a sensitive file with this DACL:

1. `ACCESS_DENIED_CALLBACK Everyone (when @Resource.Classification == "TopSecret" && Not_Member_of({Cleared-SID}))` — deny everyone when the file is TopSecret and they are not in the Cleared group
2. `ACCESS_ALLOWED Authenticated_Users GENERIC_READ` — allow read to authenticated users
3. `ACCESS_ALLOWED Authenticated_Users GENERIC_WRITE` — allow write to authenticated users

The resource attribute is set on the file's SACL: `Classification = "TopSecret"`.

User Alice, who is not in the Cleared group, tries to read.

- ACE 1: matches Alice (Everyone matches all tokens). Expression: `Classification == "TopSecret"` is TRUE (the file's resource attribute matches), `Not_Member_of({Cleared-SID})` is TRUE (Alice is not in the group). Both TRUE, so AND is TRUE. The deny applies. `FILE_READ_DATA` and friends are decided and not granted.
- ACE 2: also matches Alice, but `FILE_READ_DATA` etc. are already decided. The allow has no effect.
- ACE 3: same.

Alice gets nothing. The conditional deny did its job.

Now user Bob, who is in the Cleared group:

- ACE 1: matches Bob. Expression: `Classification == "TopSecret"` is TRUE, `Not_Member_of({Cleared-SID})` is FALSE. TRUE AND FALSE is FALSE. The deny does not apply.
- ACE 2: matches Bob. `FILE_READ_DATA` not yet decided. Granted.
- ACE 3: matches Bob. `FILE_WRITE_DATA` not yet decided. Granted.

Bob gets read and write. The conditional deny knew not to apply to him.

Now suppose the file's classification attribute is missing (administrative mistake):

- ACE 1: expression `Classification == "TopSecret"` is UNKNOWN (the attribute does not exist). AND of UNKNOWN with anything: depends on the other operand. Even if `Not_Member_of` is TRUE, UNKNOWN AND TRUE is UNKNOWN. The deny ACE has UNKNOWN, which **applies** (denies err on the side of denying). `FILE_READ_DATA` is decided as denied.
- ACE 2, 3: same fate; already decided.

Both Alice and Bob get nothing. The missing attribute caused the deny to fire, not the allow to grant. Fail safe.

## When conditional ACEs are the right tool

Conditional ACEs are the right tool when:

- The set of principals who should have access varies based on attributes that change more often than the SD does. A "members of the Engineering department who joined after 2020" rule is one expression; encoding it as a group membership would require maintaining the group as people join.
- Access depends on something that is not the principal's identity at all — the resource's classification, the time of day, the integrity of the request.
- The same DACL needs to express many cases without duplication. One conditional ACE covers what would otherwise be N parallel ACEs.

They are the wrong tool when:

- The condition reduces to "is this user in this group?". Use a group SID and a plain ACE; it is simpler and faster.
- The condition is a static identity check at a fixed point in time. Use a SID and a plain ACE.
- The expression depends on state that changes faster than you can update the SD. Conditional ACEs evaluate at access-check time using the data on the token and object *now*; if the relevant data is stale, the result is stale.

Most DACLs in practice use no conditional ACEs at all. They appear in the SDs of objects whose access policy is genuinely attribute-driven — central access policies, data-classification-driven sharing, time-bound access. For everything else, plain ACEs are clearer.

## Where to go next

For the object-side attributes that `@Resource.*` references resolve against, read [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md).

For the token-side attributes behind `@User.*` and `@Device.*`, read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

---

# Resource attributes

_Peios / Peios Security Fundamentals / Security Descriptors_

> A typed key-value attribute attached to an object through its SACL — referenced by conditional ACEs as @Resource.<name>, never granting anything by itself.

A **resource attribute** is a typed key-value attribute attached to an object — much like a claim on a token, but applied to the resource rather than the principal. A file might have `Classification = "Internal"`. A registry key might have `Department = "Finance"`. A process or token might have a `Compliance = TRUE` attribute. These attributes do not grant or deny access by themselves; they are inputs that **conditional ACEs** can reference as `@Resource.<name>`.

Resource attributes are stored in the SACL using the `SYSTEM_RESOURCE_ATTRIBUTE_ACE` type. They share the binary format used by token claims: same value types, same flags, same case-sensitivity rules. The key difference is location — claims live on a token, resource attributes live on an object's SD.

## What a resource attribute looks like

Each resource attribute carries:

- A **name** — a string, conventionally `Namespace.Attribute` (`Classification`, `Department`, `Project.Code`).
- A **value** — typed. One of `INT64`, `UINT64`, `STRING`, `SID`, `BOOLEAN`, `OCTET`. Single or multi-valued, but homogeneous (no mixed-type arrays).
- A set of **flags** controlling how the attribute participates in access checks.

The flags are the same set used for claims:

| Flag | Effect |
|---|---|
| `DISABLED` (0x0010) | The attribute is invisible to all conditional expressions. Effectively does not exist. |
| `USE_FOR_DENY_ONLY` (0x0004) | The attribute is invisible to conditions on allow ACEs, but visible to conditions on deny ACEs. Lets an administrator quickly demote an attribute. |
| `MANDATORY` (0x0020) | The attribute cannot be removed or modified without `SeTcbPrivilege`. Used for system-maintained attributes. |
| `CASE_SENSITIVE` (0x0002) | String and octet comparisons against this attribute are case-sensitive. Default is case-insensitive. |

These are the same flags described on [Claims on a token](/peios/security-fundamentals/identity/claims.md), applied here to attributes on the resource side.

## Storage in the SACL

A resource attribute lives in a `SYSTEM_RESOURCE_ATTRIBUTE_ACE` (type `0x12`). The ACE body uses a single-SID layout where the SID is always **`Everyone`** (`S-1-1-0`); the SID slot is structural, not semantic — resource attribute ACEs do not match against tokens like access-control ACEs do. The real payload is in the ApplicationData portion, which carries one claim entry in the same format as a token claim.

| ACE field | Resource attribute value |
|---|---|
| AceType | `SYSTEM_RESOURCE_ATTRIBUTE_ACE` (0x12) |
| Mask | Reserved. Not used in access decisions. |
| SID | Always `S-1-1-0` (Everyone). |
| ApplicationData | One claim entry: name, type, flags, value(s). |

A single SACL can hold many resource attribute ACEs — one per attribute. The full ABI layout of the claim entry is in the [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md).

Two structural notes:

- **The first non-inherit-only ACE for each name wins.** If two `SYSTEM_RESOURCE_ATTRIBUTE_ACE` ACEs in the SACL define the same attribute name, only the first one is used. Duplicates after the first are ignored.
- **Resource attributes are located by type-scan, not position.** Unlike DACL ACEs, the order of resource attribute ACEs within a SACL is not load-bearing beyond the first-wins tiebreak. The access check finds them by scanning for the type, not by walking.

## How resource attributes are used

When the access check evaluates a conditional ACE that references `@Resource.<name>`, the lookup runs:

1. Scan the object's SACL for a `SYSTEM_RESOURCE_ATTRIBUTE_ACE` whose attribute name matches `<name>`.
2. If found, read the value(s) and use them in the expression.
3. If not found, the reference produces UNKNOWN. The expression continues with that value, subject to three-valued logic.

The "not found" case is what makes the design tolerant of schema evolution. A conditional ACE that references `@Resource.Classification` works whether or not the specific object has a Classification attribute — if it has one, the expression uses it; if not, the expression evaluates UNKNOWN at that subexpression, and the [three-valued logic](/peios/security-fundamentals/security-descriptors/conditional-aces.md) handles the rest.

Resource attributes do not need to be referenced to exist. An object can carry attributes that no current ACE references — perhaps the schema is being prepared for future use, or the attributes are read by separate tooling that scans the SACL directly. The access check ignores resource attribute ACEs that no conditional expression names; they are inert from its perspective.

## Setting and modifying resource attributes

Resource attributes are part of the SACL. Modifying them goes through `kacs_set_sd` with `SACL_SECURITY_INFORMATION` in the security-information mask, which requires `ACCESS_SYSTEM_SECURITY` on the object. In practice this means a caller holding `SeSecurityPrivilege` — the same privilege that gates other SACL modifications.

One additional rule: a resource attribute marked `MANDATORY` cannot be removed or modified except by a caller holding `SeTcbPrivilege`. This is the lever for system-maintained attributes — values the kernel or a TCB component sets that ordinary administrators should not be able to overwrite.

Replacing the SACL with a new one that omits a `MANDATORY` attribute is also rejected; the kernel checks the diff, not just the new value.

## Namespacing

Resource attribute names are arbitrary strings. By convention, attributes use a namespace prefix:

- `Project.Code` — the project this resource belongs to.
- `Department.Name` — the owning department.
- `Compliance.Status` — a compliance classification.

The convention is just a convention. The access check compares names as strings; `Project.Code` and `ProjectCode` are different attributes. Establishing a stable naming scheme is the administrator's job.

In the conditional expression, the reference is `@Resource.<name>` regardless of whether the name itself contains dots:

- `@Resource.Project.Code` references an attribute named exactly `Project.Code`.
- `@Resource.Department` references an attribute named exactly `Department`.

The expression parser treats the dot as part of the attribute name, not as a structural separator.

## Resource attributes vs claims vs local context

Resource attributes sit alongside two other attribute sources that conditional expressions can reference. The three together give the conditional model its expressiveness:

| Source | Lives on | Referenced as | Set by |
|---|---|---|---|
| User claims | Token | `@User.<name>` | authd, from the user directory object |
| Device claims | Token | `@Device.<name>` | authd, from the machine directory object |
| **Resource attributes** | **Object SACL** | **`@Resource.<name>`** | **Object administrator, via `kacs_set_sd`** |
| Local claims | Per AccessCheck call | `@Local.<name>` | The caller, passed as a parameter |

The four namespaces let a single conditional expression weave together "who is the caller", "what machine are they on", "what is the object's property", and "what is the runtime context". The most common pattern uses two of them — caller and resource — for classification-based access. The remaining two appear when machine identity matters or when the calling code wants to supply runtime context.

## What resource attributes are not

A few things resource attributes look like at a glance but are not:

- **Resource attributes are not part of the data.** They are policy metadata, stored in the SACL, separate from the object's content. Reading the object's data does not give you access to the attribute, and vice versa.
- **Resource attributes are not ACEs themselves.** They live in `SYSTEM_RESOURCE_ATTRIBUTE_ACE` entries, but the ACE form is structural — the attribute does not grant or deny anything. The access check skips it during the DACL walk and during the SACL audit walk.
- **Resource attributes do not propagate via inheritance.** They are not inherited from a parent object to a child. A child inherits the parent's inheritable DACL/SACL access-control ACEs; resource attribute ACEs do not have inheritance semantics. If you want the same attribute on every file in a directory, you set it on each one (or use tooling that walks the tree).
- **Resource attributes are not searchable through the access check.** The access check uses attributes for evaluating expressions, not for filtering objects. There is no "list all objects where `@Resource.Classification == 'Public'`" API. That would be a job for a separate indexing layer.

## Where to go next

For the SACL these attributes live in — and everything else stored alongside them — read [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md).

For the principal-side counterpart to resource attributes, read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

---

# The SACL

_Peios / Peios Security Fundamentals / Security Descriptors_

> The system half of a security descriptor — audit and alarm ACEs, the integrity label, the PIP trust label, scoped policy references, and resource attributes.

The **SACL** — System Access Control List — is the half of a security descriptor that holds system-level policy. Where the DACL decides "who can do what to this object", the SACL covers everything else the kernel needs to know about the object for security purposes: which access attempts to audit, what integrity level the object has, what PIP trust label it carries, which central access policies apply to it, and what attributes it exposes for conditional evaluation.

The SACL is not part of the access-grant decision in the way the DACL is. Most of its entries do not grant or deny anything; they configure policy that runs alongside or after the DACL walk. The two access-related entries — `SYSTEM_AUDIT` and `SYSTEM_ALARM` — fire events but do not gate access.

This page covers each type of SACL entry, how the access check consumes it, and the special status of SACL modification.

## What the SACL holds

A SACL is an ACL — same on-wire structure as a DACL, the same `AceCount`/`AclSize` header and the same ACE format. But the ACE types it holds are different. While a DACL is dominated by access-control types (`ACCESS_ALLOWED`, `ACCESS_DENIED`, callbacks), a SACL holds the system-policy types:

| ACE type | Purpose |
|---|---|
| `SYSTEM_AUDIT` | Fire an audit event when access matches. One-shot at handle creation. |
| `SYSTEM_AUDIT_OBJECT` | Same, GUID-scoped. |
| `SYSTEM_AUDIT_CALLBACK` | Same, with a conditional expression gating when the audit fires. |
| `SYSTEM_AUDIT_CALLBACK_OBJECT` | Same, GUID-scoped and conditional. |
| `SYSTEM_ALARM` | Configure per-operation continuous audit on an open handle. |
| `SYSTEM_ALARM_OBJECT` | Same, GUID-scoped. |
| `SYSTEM_ALARM_CALLBACK` | Same, conditional. |
| `SYSTEM_ALARM_CALLBACK_OBJECT` | Same, GUID-scoped and conditional. |
| `SYSTEM_MANDATORY_LABEL` | The object's mandatory integrity label and policy bits (MIC). |
| `SYSTEM_RESOURCE_ATTRIBUTE` | A typed key-value attribute on the object (covered separately under [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md)). |
| `SYSTEM_SCOPED_POLICY_ID` | A reference to a central access policy (CAAP). |
| `SYSTEM_PROCESS_TRUST_LABEL` | The object's PIP trust label and the explicit allowed mask for non-dominant callers. |

The numeric type values are in the canonical catalog, [ACE types and flags](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).

A SACL can mix these in any combination. Most SACLs in practice are small — a few audit ACEs, perhaps a mandatory label, sometimes a resource attribute or two. SACLs with every type populated are rare and exist only on the most highly-protected objects.

## Audit ACEs: one event per access attempt

A `SYSTEM_AUDIT` ACE specifies: when **this principal** attempts **these rights** on the object, emit an audit event if the access succeeded (`SUCCESSFUL_ACCESS_ACE_FLAG` set) or if it failed (`FAILED_ACCESS_ACE_FLAG` set) or both.

The event fires at the moment the access check completes. One event per matching audit ACE per access attempt. A handle opened with `FILE_READ_DATA | FILE_WRITE_DATA` against an SD with an audit ACE matching both rights produces one event covering both. A separate handle later, with separate access checks, produces separate events.

Audit ACEs match like other ACEs: the SID in the ACE is compared against the caller's identity. Audit ACE matching uses **deny polarity** — it considers the broadest identity view, including deny-only groups, so that any identity the caller has that should produce an audit event will produce one even if it would not match an allow ACE.

The callback variants (`SYSTEM_AUDIT_CALLBACK`, etc.) add a conditional expression. If the expression evaluates TRUE or UNKNOWN, the event fires; if FALSE, it does not. This is why conditional audit fails open — recording something is the safe choice when uncertain.

The full audit model is covered in [Auditing](/peios/security-fundamentals/auditing/overview.md).

## Alarm ACEs: an event per operation

`SYSTEM_ALARM` configures **continuous** auditing, not one-shot. The ACE's mask is recorded on the open handle as a **continuous audit mask**. From then on, every operation performed on the handle has its required-access mask compared against the continuous mask; if any bit overlaps, an event fires.

The difference between AUDIT and ALARM is in granularity. AUDIT records "this principal opened this object with these rights". ALARM records "this principal opened this object with these rights, and then performed this specific operation, then this one, then this one". The trade-off is event volume — alarm ACEs on a hot path can produce many events; audit ACEs produce one per handle.

ALARM is the right tool for objects where post-open behaviour is what matters: an audited credential store, a sensitive log file, a security-critical configuration. For most objects, AUDIT is enough.

## SYSTEM_MANDATORY_LABEL: the integrity floor

A `SYSTEM_MANDATORY_LABEL` ACE sets the object's mandatory integrity level. The ACE has two parts:

- A **SID** from the `S-1-16-*` integrity namespace, whose single sub-authority is the numeric level (`S-1-16-8192` for Medium, `S-1-16-12288` for High, and so on — any single-sub-authority `S-1-16` value is a valid level, compared numerically).
- A **mask** containing the MIC policy bits: `SYSTEM_MANDATORY_LABEL_NO_READ_UP` (0x01), `SYSTEM_MANDATORY_LABEL_NO_WRITE_UP` (0x02), `SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP` (0x04).

When the access check evaluates the SD, it scans for a `SYSTEM_MANDATORY_LABEL` ACE. The first non-inherit-only one is the object's effective label. If none is present, the object's effective integrity is **Medium with `NO_WRITE_UP`** (the default).

A caller whose effective token's integrity level is below the object's label cannot perform the right categories blocked by the policy bits. The check runs before the DACL walk. A non-dominant caller's deny is decided before any allow ACE in the DACL has a chance to grant the same right.

The label is independent of identity. Two users at different integrity levels with identical SIDs see the same DACL but different effective access, because the label-vs-token-integrity comparison happens first.

MIC is covered in detail in [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

## SYSTEM_PROCESS_TRUST_LABEL: the PIP gate

A `SYSTEM_PROCESS_TRUST_LABEL` ACE opts the object in to process integrity protection (PIP). The ACE has:

- A **SID** of the form `S-1-19-T-L` where `T` is the PIP type and `L` is the trust level.
- A **mask** specifying the rights that non-dominant callers are explicitly allowed.

PIP enforcement is two-step. First, the access check determines whether the caller's PSB **dominates** the object's trust label — that is, the caller's `pip_type` is at least the ACE's type AND the caller's `pip_trust` is at least the ACE's trust level. If the caller dominates, the trust label imposes no restrictions. If the caller does not dominate, **only** the rights in the ACE's mask are permitted, and privilege-granted rights are revoked (which is what makes PIP different from MIC).

Objects without a PIP trust label ACE are unprotected — PIP is opt-in. The access check does not impose PIP rules unless the SACL specifically asks for them.

PIP is covered in [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md).

## SYSTEM_SCOPED_POLICY_ID: central access policies

A `SYSTEM_SCOPED_POLICY_ID` ACE references a centrally-defined access policy by SID. The policy itself is distributed by authd (from loregd on standalone machines, from the machine's directory source on domain-joined ones) and held in a kernel cache. When the access check sees this ACE, it looks up the referenced policy and evaluates its rules in addition to the object's own DACL — intersecting the result, never widening.

Multiple `SYSTEM_SCOPED_POLICY_ID` ACEs in one SACL apply multiple policies. The result is the intersection of all of them with the object's DACL.

The mechanism — applies-to expressions, staged vs effective rules, the recovery policy for when a referenced policy is missing — lives in [Central access policies](/peios/security-fundamentals/central-access-policies/overview.md). For this page, what matters is that the entry exists, sits in the SACL, and is looked up by SID at access time.

## SYSTEM_RESOURCE_ATTRIBUTE: typed properties on the object

`SYSTEM_RESOURCE_ATTRIBUTE` carries a typed key-value attribute on the object — referenceable as `@Resource.<name>` in conditional expressions on this or any other SD. The structural role is described above; the full story is in [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md).

## How the SACL is consumed

The access check consults the SACL twice, at different points:

1. **Before the DACL walk**, the check scans the SACL for the MIC, PIP, and scoped-policy ACEs. MIC and PIP pre-decide certain bits as denied for non-dominant callers; scoped policies are noted for later evaluation. Resource attribute ACEs are scanned and indexed so that conditional expressions in the DACL can reference them.
2. **After the DACL walk**, the check scans the SACL again for `SYSTEM_AUDIT` and `SYSTEM_ALARM` ACEs and decides which events to fire. The MIC, PIP, and scoped-policy ACEs are not consulted again here — their job is done.

This two-pass shape is why the SACL is "structural" rather than "evaluated": its job is to set policy for the DACL walk and to record audit about the DACL walk's outcome, not to participate in the walk itself.

## Modifying the SACL

The DACL is gated by `WRITE_DAC`, which the owner has implicitly. The SACL is gated by `ACCESS_SYSTEM_SECURITY`, which the owner does **not** have implicitly. To modify a SACL, a caller needs:

- Either `SeSecurityPrivilege` (which grants `ACCESS_SYSTEM_SECURITY` on any object), or
- `SeRestorePrivilege` (which grants it for specific `kacs_set_sd` use cases — backup/restore tooling).

`WRITE_DAC` does **not** grant `ACCESS_SYSTEM_SECURITY`. An owner who can rewrite a DACL freely cannot touch the SACL. This is deliberate: the SACL holds audit policy and system-level constraints that the object's owner should not be able to unilaterally remove. An audited object's audit policy is the administrator's, not the user's.

Modifying a SACL that contains a `MANDATORY`-flagged resource attribute is further restricted: removing or changing a mandatory attribute requires `SeTcbPrivilege` (covered under [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md)).

## The LABEL_SECURITY_INFORMATION shortcut

The `kacs_set_sd` syscall takes a `security_information` bitmask saying which SD components to update. Two of the bits are relevant here:

| Flag | Effect |
|---|---|
| `SACL_SECURITY_INFORMATION` (0x08) | Update the SACL (all of it). Requires `ACCESS_SYSTEM_SECURITY`. |
| `LABEL_SECURITY_INFORMATION` (0x10) | Update **only** the mandatory integrity label ACE within the SACL. |

`LABEL_SECURITY_INFORMATION` is the right tool when you want to change an object's integrity level without touching its audit policy. Setting an integrity label downward (to a level at or below the caller's own integrity level) is allowed; setting it above requires `SeRelabelPrivilege`.

The two flags are mutually exclusive in a single `kacs_set_sd` call. You either replace the entire SACL or update only the label, not both at once.

## What the SACL is not

A few clarifications:

- **The SACL is not for "extra security".** It is the kernel's place for system-level policy. Putting more entries in the SACL does not make an object more secure; it just records more policy.
- **The SACL is not where the DACL goes if you forget the DACL.** The two lists are structurally distinct in the SD format; an SD with a missing DACL but a populated SACL has a NULL DACL (grant all access) regardless of what the SACL says.
- **The SACL is not visible to ordinary users.** Reading the SACL — `kacs_get_sd` with `SACL_SECURITY_INFORMATION` — also requires `ACCESS_SYSTEM_SECURITY`. An owner can read their object's DACL freely but cannot read its SACL without the same privilege required to modify it.

## Where to go next

For the events audit and alarm ACEs produce, and where they go, read [Auditing](/peios/security-fundamentals/auditing/overview.md).

For how the SACL's labels and policies fit into the full check pipeline, read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

To read and edit a SACL from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# The sd command

_Peios / Peios Security Fundamentals / Security Descriptors_

> The sd command reads and changes a file's security descriptor — owner, access rules, audit rules, integrity label, and inheritance.

`sd` is the command-line tool for working with **security descriptors** on files. Everything this topic describes — owners, DACLs, ACEs, the SACL, integrity labels, inheritance — `sd` is how you read it and change it from a shell.

```
sd subcommand path [arguments]
```

```
$ sd show ./report.txt
$ sd allow ./report.txt alice:read
$ sd owner ./report.txt BA
```

Where [`ls -l`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) shows a file's owner and a summary, and [`cp --preserve`](/peios/using-peios/peiosutils/files-and-directories/cp.md) carries a descriptor across, `sd` is the tool that edits the descriptor directly.

## The subcommands

| Group | Subcommand | Does |
|---|---|---|
| Inspect | `show` | Print the descriptor on a path. |
| | `check` | Simulate an access check against the path. |
| DACL | `allow` | Add an allow rule for one or more principals. |
| | `deny` | Add a deny rule for one or more principals. |
| | `remove` | Drop every DACL rule for the named principals. |
| Auditing | `audit` | Add an audit rule to the SACL. |
| | `unaudit` | Drop every SACL rule for the named principals. |
| Ownership | `owner` | Set the descriptor's owner. |
| | `group` | Set the descriptor's group. |
| Integrity | `integrity` | Set the mandatory integrity label. |
| Inheritance | `inherit` | Turn inheritance protection on or off. |
| | `reset` | Drop the file's own rules and re-inherit from the parent. |
| | `propagate` | Push inheritance down to descendants. |
| Wholesale | `set` | Replace the entire descriptor at once. |

## Naming a principal

Wherever a subcommand takes a `PRINCIPAL`, it accepts any of:

| Form | Example | Meaning |
|---|---|---|
| `@self` | `@self` | The user SID of the token running `sd`. |
| `@owner` | `@owner` | A placeholder that the access check substitutes with the file's own owner. |
| A well-known label | `Everyone`, `Administrators`, `LocalSystem` | A named built-in principal. |
| A two-letter alias | `WD`, `BA`, `SY` | The short alias for a well-known principal. |
| A raw SID | `S-1-5-32-544` | Any SID, written out in full. |

See [SIDs](/peios/security-fundamentals/identity/sids.md) for what these are.

## Naming permissions

Wherever a subcommand takes `PERMS`, several notations are accepted, and may be mixed:

| Form | Example | Meaning |
|---|---|---|
| Single letters | `rwx`, `r`, `m` | `r` read, `w` write, `x` execute, `d` delete, `m` modify, `f` full, `c` change-permissions, `o` take-ownership. |
| Words | `read,write`, `modify` | The same set, spelled out. |
| Fine-grained names | `read-data,append,traverse` | Individual low-level rights, for precise rules. |
| Raw hex | `0x1F01FF` | An access mask written directly. |

Run letters together (`rwx`) or separate names with commas (`read,write,execute`).

## Inspecting

### `sd show`

Prints the descriptor on a path — the owner, the group, the DACL, the SACL.

```
$ sd show ./report.txt
```

| Flag | Effect |
|---|---|
| `--sddl` | Render the descriptor as an SDDL string. |
| `--raw` | Render SIDs in raw `S-1-…` form only. |
| `--label` | Render SIDs as their labels where known. |
| `--all` | Verbose — decode every flag and show raw masks alongside. |
| `--json` | Emit JSON. |

### `sd check`

Simulates an [access decision](/peios/security-fundamentals/access-decisions/overview.md): "would this access be allowed?" — without performing it.

```
$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
```

| Argument / flag | Effect |
|---|---|
| `PERMS` | The access to test for. |
| `--pid PID` | Check against process `PID`'s token instead of your own. |
| `--explain` | Show why the decision came out as it did — the rule-by-rule walk. |

`sd check` is the first thing to reach for when an access is denied and you do not know why. `--explain` walks the descriptor the same way the kernel does.

## Changing the DACL

The DACL is the list of allow and deny rules. These three subcommands edit it.

### `sd allow` and `sd deny`

Add an allow (or deny) rule for one or more `PRINCIPAL:PERMS` pairs.

```
$ sd allow ./report.txt alice:read bob:rw
$ sd deny  ./report.txt Everyone:write
```

| Flag | Effect |
|---|---|
| `--flags LIST` | ACE inheritance flags — `CI` container-inherit, `OI` object-inherit, `NP` no-propagate, `IO` inherit-only; `none` clears them. |
| `--if EXPR` | Make it a [conditional rule](/peios/security-fundamentals/security-descriptors/conditional-aces.md), applied only when `EXPR` is true. |
| `--replace` | Drop any existing rules for this principal and kind first, instead of appending. |
| `--recursive`, `-r` | Apply to every descendant of the path. |

Remember that a **deny** rule, when it matches, wins over any allow — see [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

### `sd remove`

Drops every DACL rule — allow and deny — for the named principals.

```
$ sd remove ./report.txt bob carol
```

| Flag | Effect |
|---|---|
| `--allow-empty` | Permit the result to be a present-but-empty DACL, which denies everyone. Without this, `sd` refuses to produce one. |
| `--recursive`, `-r` | Apply to every descendant. |

## Auditing

### `sd audit` and `sd unaudit`

The SACL holds audit rules — see [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md). `sd audit` adds one; `sd unaudit` drops every SACL rule for the named principals.

```
$ sd audit ./secrets.db Everyone:write:failure
```

An audit spec is `PRINCIPAL:PERMS:WHEN`, where `WHEN` is `success`, `failure`, or `both` — which outcomes to log. `sd audit` takes the same `--flags`, `--if`, `--replace`, and `-r` options as `sd allow`.

## Ownership

### `sd owner` and `sd group`

Set the descriptor's owner or group SID.

```
$ sd owner ./report.txt alice
$ sd group ./report.txt Administrators
```

Both take a single `PRINCIPAL` and accept `-r`. Changing an owner is itself an access-controlled act — see [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md).

## Integrity

### `sd integrity`

Sets the file's mandatory integrity label.

```
$ sd integrity ./report.txt high
```

The level is one of `untrusted`, `low`, `medium`, `medium-plus`, `high`, `system`, `protected`. The standard catalog is five levels (`untrusted`, `low`, `medium`, `high`, `system`); `medium-plus` (RID 8448) and `protected` (RID 20480) are non-standard Windows-compatibility levels — the kernel compares any `S-1-16-<rid>` numerically.

| Flag | Effect |
|---|---|
| `--policy BITS` | The label's policy bits, comma-separated: `NW` no write-up, `NR` no read-up, `NX` no execute-up. |
| `--recursive`, `-r` | Apply to every descendant. |

For what the label does, see [Mandatory integrity control](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

## Inheritance

### `sd inherit`

Turns inheritance protection on or off — the `+` mark `ls -l` shows.

```
$ sd inherit off ./report.txt    # lock the file; stop inheriting
$ sd inherit on  ./report.txt    # let it inherit from its parent again
```

`inherit on` lets the file inherit rules from its parent directory. `inherit off` protects the file — it keeps its current rules and stops tracking the parent.

| Flag | Effect |
|---|---|
| `--strip-inherited` | When turning protection off, also drop the inherited rules already on the file. |
| `--recursive`, `-r` | Apply to every descendant. |

### `sd reset`

Drops the file's own explicit rules and rebuilds its DACL purely from what the parent directory hands down — returning the file to "inherits everything".

### `sd propagate`

Pushes this directory's inheritable rules down into its descendants, refreshing what they inherit. Use it after changing a directory's rules so the children pick up the change.

See [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md) for the full model.

## Replacing the whole descriptor

### `sd set`

Replaces the entire descriptor in one step.

```
$ sd set ./report.txt 'O:BAG:BAD:P(A;;FA;;;BA)(A;;0x1200a9;;;BU)'
```

| Argument / flag | Effect |
|---|---|
| `SDDL` | The new descriptor as an SDDL string. `-` reads it from standard input. |
| `--binary FILE` | Instead of SDDL, read the raw descriptor bytes from `FILE` (`-` for standard input). |
| `--components LIST` | Override which parts of the descriptor (owner, group, DACL, SACL) the operation writes. |

## Common flags

These apply across the subcommands:

| Flag | Effect |
|---|---|
| `--recursive`, `-r` | Apply the change to every descendant of the path. |
| `--no-follow-symlinks`, `-P` | Operate on a symbolic link itself, not the file it points to. |
| `--json` | Emit JSON instead of human-readable output. |

## Exit status

| Code | Meaning |
|---|---|
| `0` | The operation succeeded — or, for `sd check`, the access would be **allowed**. |
| non-zero | The operation failed, the path was unreachable, or — for `sd check` — the access would be **denied**. |
