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

Security Descriptors

Single-page view · as markdown

Security descriptors

Peios / Peios Security Fundamentals / Security Descriptors

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

ComponentWhat it does
OwnerA single SID identifying who owns the object. The owner has implicit READ_CONTROL and WRITE_DAC rights (with one exception, covered in Ownership).
Primary groupA 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).

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 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 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 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 typeStorage
Files and directoriesAn xattr (security.peios.sd, or system.ntfs_security on NTFS volumes) on the inode.
Registry keysStored by the registry source.
ProcessesA field on the PSB (process security block).
TokensA field on the token object itself.
Abstract Unix socketsA field on the socket LSM blob, stamped from the binding thread's effective token at bind().
IPC objectsA 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 changeYou need
The DACLWRITE_DAC on the object (the owner always has this unless suppressed by OWNER RIGHTS).
The ownerWRITE_OWNER on the object, plus a restriction on which SIDs you can name as the new owner (covered in Ownership).
The SACLACCESS_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.
  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; 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.

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.

If you want to know how owner implicit rights work and why OWNER RIGHTS can suppress them, read Ownership and implicit rights.

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.

If you want conditional ACEs — the ABAC-style mechanism where access depends on token claims and resource attributes — read Conditional ACEs.

If you want the SACL specifically — audit, alarm, integrity labels, central access policy references, PIP trust labels — read The SACL.

To read and change a descriptor from a shell — owner, DACL, SACL, label, inheritance — read The sd command.

ACLs, ACEs, and access masks

Peios / Peios Security Fundamentals / Security Descriptors

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.

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.

The detailed byte-level layouts live in the Wire formats reference. 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; the tables here describe what each type is for.

Access-control ACE family #

These ACEs participate in the DACL walk.

TypeBodyUse
ACCESS_ALLOWEDSingle SIDGrant the listed rights to the named principal.
ACCESS_DENIEDSingle SIDDeny the listed rights to the named principal.
ACCESS_ALLOWED_COMPOUNDReserved (not used in v0.20).—
ACCESS_ALLOWED_OBJECTObject ACEGrant rights, scoped by property GUID or inherited only by certain child object types.
ACCESS_DENIED_OBJECTObject ACEDeny rights, same scoping.
ACCESS_ALLOWED_CALLBACKSingle SID + expressionGrant rights only if the conditional expression evaluates TRUE.
ACCESS_DENIED_CALLBACKSingle SID + expressionDeny rights when the expression evaluates TRUE or UNKNOWN.
ACCESS_ALLOWED_CALLBACK_OBJECTObject ACE + expressionGrant rights, conditionally, scoped by GUID.
ACCESS_DENIED_CALLBACK_OBJECTObject ACE + expressionDeny 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.

TypeBodyUse
SYSTEM_AUDITSingle SIDEmit an audit event when the named principal attempts the listed rights. Flags decide whether success, failure, or both.
SYSTEM_AUDIT_OBJECTObject ACESame, scoped by GUID.
SYSTEM_AUDIT_CALLBACKSingle SID + expressionAudit conditionally. UNKNOWN result emits the event (fail-open for auditing).
SYSTEM_AUDIT_CALLBACK_OBJECTObject ACE + expressionConditional, scoped by GUID.
SYSTEM_ALARMSingle SIDConfigure per-operation continuous audit on the open handle. Each matching operation produces an event.
SYSTEM_ALARM_OBJECTObject ACESame, scoped by GUID.
SYSTEM_ALARM_CALLBACKSingle SID + expressionConditional continuous audit.
SYSTEM_ALARM_CALLBACK_OBJECTObject ACE + expressionConditional, 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 and Auditing for the full story.

System-policy ACE family #

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

TypeBodyUse
SYSTEM_MANDATORY_LABELIntegrity SID + maskThe object's mandatory integrity label and the policy bits that govern who can write/read/execute up.
SYSTEM_RESOURCE_ATTRIBUTEEveryone SID + claim entryA name-value attribute on the object, referenceable as @Resource.<name> in conditional expressions.
SYSTEM_SCOPED_POLICY_IDPolicy SIDA reference to a central access policy that should be evaluated alongside the object's own DACL.
SYSTEM_PROCESS_TRUST_LABELPIP SID + maskThe 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, resource attributes under Resource attributes, CAAP under Central access policies, PIP under Process integrity protection).

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.)

FlagEffect
OBJECT_INHERIT_ACEThe ACE inherits to non-container child objects (files, not directories).
CONTAINER_INHERIT_ACEThe ACE inherits to container child objects (directories).
NO_PROPAGATE_INHERIT_ACEThe 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_ACEThe ACE is for inheritance only. The access check skips it; it exists only to be copied to children.
INHERITED_ACESet 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:

FlagsEffect
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_ACEApply to descendants but not to this object.
CI | OI | NO_PROPAGATE_INHERIT_ACEApply to immediate children only, not grandchildren.

The full inheritance machinery is on Inheritance.

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.

RegionBitsPurpose
Object-specific rights0–1516 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 rights16–205 bits shared across all object types: DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE.
Reserved21–23Must be zero.
Special rights24–25ACCESS_SYSTEM_SECURITY — gates SACL read/write. MAXIMUM_ALLOWED — a request flag, not a real right.
Reserved26–27Must be zero.
Generic rights28–31GENERIC_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; the mask's byte layout is in the SD wire format. 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 typeGENERIC_READ maps to
FileFILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE
TokenTOKEN_QUERY | READ_CONTROL
ProcessPROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL

The full GenericMapping tables live in the Constants and catalogs 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. 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.

For the full numeric catalog of ACE types, flags, and access-mask bits, see ACE types and flags.

To read and edit ACLs from a shell, read The sd command.

DACL evaluation

Peios / Peios Security Fundamentals / Security Descriptors

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.

PositionACE class
1Explicit deny ACEs (set directly on the object).
2Explicit allow ACEs.
3Inherited deny ACEs.
4Inherited 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.

StateWhat the SD looks likeWhat the access check does
NULL DACLSE_DACL_PRESENT flag is not set in the SD's control flags.Grants every valid right. The walk is skipped.
Empty DACLSE_DACL_PRESENT is set, the DACL exists, but contains zero ACEs.Grants no rights (except owner implicit rights — see Ownership). 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:

FlagEffect 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_ACENo 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_FLAGThese 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.
  • 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.

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.

For the full pipeline the walk sits inside — MIC, PIP, privileges, narrowing layers — read Access decisions.

To test what a DACL would grant from a shell, read The sd command.

Ownership and implicit rights

Peios / Peios Security Fundamentals / Security Descriptors

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.

For how SeTakeOwnership and SeRestore fit the wider privilege model, read Privileges.

To view and change an object's owner from a shell, read The sd command.

Inheritance

Peios / Peios Security Fundamentals / Security Descriptors

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 page:

FlagEffect 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:

FlagsMeaning
CI | OIInherit to all descendants, files and directories, recursively. The most common pattern.
CIInherit to descendant directories only.
OIInherit to descendant files only.
CI | OI | IOApply to descendants but not to this object.
CI | OI | NPApply 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). 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:

ComponentFirst sourceSecond sourceThird source
OwnerCreator's explicit SD (if owner present)Creator's token's default owner(none — must be present)
Primary groupCreator's explicit SD (if group present)Creator's token's default primary group(none — must be present)
DACLCreator's explicit SD (if DACL present)Creator's token's default_dacl(no DACL means NULL DACL)
SACLCreator'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.

FlagEffect
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:

FlagEffect
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.

To sweep a tree and propagate inheritable ACEs from a shell, read The sd command.

Conditional ACEs

Peios / Peios Security Fundamentals / Security Descriptors

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:

TypeBehaviour
ACCESS_ALLOWED_CALLBACKAllow ACE, gated by expression.
ACCESS_DENIED_CALLBACKDeny ACE, gated by expression.
ACCESS_ALLOWED_CALLBACK_OBJECTAllow ACE, gated by expression, scoped by GUID.
ACCESS_DENIED_CALLBACK_OBJECTDeny ACE, gated by expression, scoped by GUID.
SYSTEM_AUDIT_CALLBACKAudit ACE, gated by expression.
SYSTEM_AUDIT_CALLBACK_OBJECTAudit ACE, gated by expression, scoped by GUID.
SYSTEM_ALARM_CALLBACKAlarm ACE, gated by expression.
SYSTEM_ALARM_CALLBACK_OBJECTAlarm 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 classTRUEFALSEUNKNOWN
Allow callbackACE applies (grants its rights)ACE is skippedACE is skipped
Deny callbackACE applies (denies its rights)ACE is skippedACE applies (denies)
Audit callbackEmit eventSkipEmit event
Alarm callbackConfigure continuous-audit maskSkipConfigure 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:

NamespaceSourceSet 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. Resource attributes are covered on Resource attributes. Local claims are passed by the caller; the API surface lives in the Kernel ABI reference.

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

FamilyOperators
Relational==, !=, <, <=, >, >=
Set / membershipContains, 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.

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).
  • 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 (&&) #

TRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

OR (||) #

TRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

NOT (!) #

InputOutput
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

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.

For the token-side attributes behind @User.* and @Device.*, read Claims on a token.

Resource attributes

Peios / Peios Security Fundamentals / Security Descriptors

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:

FlagEffect
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, 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 fieldResource attribute value
AceTypeSYSTEM_RESOURCE_ATTRIBUTE_ACE (0x12)
MaskReserved. Not used in access decisions.
SIDAlways S-1-1-0 (Everyone).
ApplicationDataOne 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.

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

SourceLives onReferenced asSet by
User claimsToken@User.<name>authd, from the user directory object
Device claimsToken@Device.<name>authd, from the machine directory object
Resource attributesObject SACL@Resource.<name>Object administrator, via kacs_set_sd
Local claimsPer 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.

For the principal-side counterpart to resource attributes, read Claims on a token.

The SACL

Peios / Peios Security Fundamentals / Security Descriptors

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 typePurpose
SYSTEM_AUDITFire an audit event when access matches. One-shot at handle creation.
SYSTEM_AUDIT_OBJECTSame, GUID-scoped.
SYSTEM_AUDIT_CALLBACKSame, with a conditional expression gating when the audit fires.
SYSTEM_AUDIT_CALLBACK_OBJECTSame, GUID-scoped and conditional.
SYSTEM_ALARMConfigure per-operation continuous audit on an open handle.
SYSTEM_ALARM_OBJECTSame, GUID-scoped.
SYSTEM_ALARM_CALLBACKSame, conditional.
SYSTEM_ALARM_CALLBACK_OBJECTSame, GUID-scoped and conditional.
SYSTEM_MANDATORY_LABELThe object's mandatory integrity label and policy bits (MIC).
SYSTEM_RESOURCE_ATTRIBUTEA typed key-value attribute on the object (covered separately under Resource attributes).
SYSTEM_SCOPED_POLICY_IDA reference to a central access policy (CAAP).
SYSTEM_PROCESS_TRUST_LABELThe 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.

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.

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.

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.

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. 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.

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).

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:

FlagEffect
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.

For how the SACL's labels and policies fit into the full check pipeline, read Access decisions.

To read and edit a SACL from a shell, read The sd command.

The sd command

Peios / Peios Security Fundamentals / Security Descriptors

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 shows a file's owner and a summary, and cp --preserve carries a descriptor across, sd is the tool that edits the descriptor directly.

The subcommands #

GroupSubcommandDoes
InspectshowPrint the descriptor on a path.
checkSimulate an access check against the path.
DACLallowAdd an allow rule for one or more principals.
denyAdd a deny rule for one or more principals.
removeDrop every DACL rule for the named principals.
AuditingauditAdd an audit rule to the SACL.
unauditDrop every SACL rule for the named principals.
OwnershipownerSet the descriptor's owner.
groupSet the descriptor's group.
IntegrityintegritySet the mandatory integrity label.
InheritanceinheritTurn inheritance protection on or off.
resetDrop the file's own rules and re-inherit from the parent.
propagatePush inheritance down to descendants.
WholesalesetReplace the entire descriptor at once.

Naming a principal #

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

FormExampleMeaning
@self@selfThe user SID of the token running sd.
@owner@ownerA placeholder that the access check substitutes with the file's own owner.
A well-known labelEveryone, Administrators, LocalSystemA named built-in principal.
A two-letter aliasWD, BA, SYThe short alias for a well-known principal.
A raw SIDS-1-5-32-544Any SID, written out in full.

See SIDs for what these are.

Naming permissions #

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

FormExampleMeaning
Single lettersrwx, r, mr read, w write, x execute, d delete, m modify, f full, c change-permissions, o take-ownership.
Wordsread,write, modifyThe same set, spelled out.
Fine-grained namesread-data,append,traverseIndividual low-level rights, for precise rules.
Raw hex0x1F01FFAn 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
FlagEffect
--sddlRender the descriptor as an SDDL string.
--rawRender SIDs in raw S-1-… form only.
--labelRender SIDs as their labels where known.
--allVerbose — decode every flag and show raw masks alongside.
--jsonEmit JSON.

sd check #

Simulates an access decision: "would this access be allowed?" — without performing it.

$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
Argument / flagEffect
PERMSThe access to test for.
--pid PIDCheck against process PID's token instead of your own.
--explainShow 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
FlagEffect
--flags LISTACE inheritance flags — CI container-inherit, OI object-inherit, NP no-propagate, IO inherit-only; none clears them.
--if EXPRMake it a conditional rule, applied only when EXPR is true.
--replaceDrop any existing rules for this principal and kind first, instead of appending.
--recursive, -rApply to every descendant of the path.

Remember that a deny rule, when it matches, wins over any allow — see DACL evaluation.

sd remove #

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

$ sd remove ./report.txt bob carol
FlagEffect
--allow-emptyPermit the result to be a present-but-empty DACL, which denies everyone. Without this, sd refuses to produce one.
--recursive, -rApply to every descendant.

Auditing #

sd audit and sd unaudit #

The SACL holds audit rules — see The SACL. 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.

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.

FlagEffect
--policy BITSThe label's policy bits, comma-separated: NW no write-up, NR no read-up, NX no execute-up.
--recursive, -rApply to every descendant.

For what the label does, see Mandatory integrity control.

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.

FlagEffect
--strip-inheritedWhen turning protection off, also drop the inherited rules already on the file.
--recursive, -rApply 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 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 / flagEffect
SDDLThe new descriptor as an SDDL string. - reads it from standard input.
--binary FILEInstead of SDDL, read the raw descriptor bytes from FILE (- for standard input).
--components LISTOverride which parts of the descriptor (owner, group, DACL, SACL) the operation writes.

Common flags #

These apply across the subcommands:

FlagEffect
--recursive, -rApply the change to every descendant of the path.
--no-follow-symlinks, -POperate on a symbolic link itself, not the file it points to.
--jsonEmit JSON instead of human-readable output.

Exit status #

CodeMeaning
0The operation succeeded — or, for sd check, the access would be allowed.
non-zeroThe operation failed, the path was unreachable, or — for sd check — the access would be denied.

Peios Learn — documentation for the Peios project.

Built with Trail.