security & ACL — access control on the graph¶
In one paragraph
Access control is a pure policy over typed entries. A vertex stores a list of
tr::graph::ace_t — subject, granted-rights mask, flags, expiry. The graph does
the walking: it merges a vertex’s own entries with the INHERIT-flagged entries
of its ancestors into an effective_acl_t, then hands that list plus the
check-time clock to the policy the build selected. The policy touches no graph
state, takes no locks and reads no clock of its own, which is what makes ACE
edge cases — expiry, inheritance, ordering — unit-testable with no live graph.
What it does¶
An ACE (access-control entry) grants a subject a set of rights on a vertex.
The subject is an opaque token, not a name the protocol interprets: how a
deployment maps a peer to a token is the deployment’s business, and the special
token EVERYONE@ matches any resolved subject. The rights are bits in
acl_right_t. An entry may carry an absolute expiry, after which it grants
nothing, and the kAceInherit flag, which is what makes it visible to
descendants.
EVERYONE@ is reserved, and the core enforces the reservation rather than
describing it (#908). The
wire has one spelling for a subject token — the acl/acl-aces vector sends
peer-a and EVERYONE@ as the same opaque VALUE — so a deployment whose
resolver passes a caller-supplied identity through (a username, a certificate
CN, a peer name) could otherwise mint a principal that is the wildcard, and an
entry meant for that one principal would grant everyone. A subject token equal
to kEveryoneSubject therefore matches nothing in either policy, and
graph_t::acl_allows refuses such a caller outright — at every gate, on a
guarded vertex and on a bare one, the same fail-closed arm the resolver’s own
error return takes. is_reserved_subject is public so a resolver can refuse it
at its own door too. EVERYONE@ is the only special subject: ADR-0020 originally
named OWNER@ alongside it, but no evaluator here ever special-cased that string,
so an OWNER@ ACE matched nobody — and since any present ACE closes an otherwise-open
vertex, such an ACE locked the vertex it was written to delegate. ADR-0020’s
erratum (#1033) withdraws the
name rather than reserving it; OWNER@ is an ordinary opaque token, and real owner
semantics would need a per-vertex owner identity the graph does not hold.
Evaluation is split in two on purpose:
The graph owns the walk. It collects the vertex’s own entries in stored order, then appends each ancestor’s
INHERIT-flagged entries, producing one merged list in evaluation order.effective_acl_tis that list plus the verdict call over it.The policy owns the decision. Given a merged list, a subject, one right bit and the current time, it answers
ALLOW,DENY, orNO_MATCH.NO_MATCHmeans no applicable entry decided the bit — the caller applies the open-by-default rule itself rather than the policy guessing.
Two policies ship, and the build picks one (see config):
Policy |
Profile |
Semantics |
|---|---|---|
|
the default, MCU-class |
ALLOW entries only, with a single |
|
host |
ordered first-match-per-bit, DENY included |
Because the choice is per-target configuration and the check runs on the data plane, it is made at compile time — a runtime branch on every access would be a cost paid by the target that does not need the feature.
The wire side¶
An ACL lives on the wire as the :acl ACL TLV described in
reference §protocol TLVs §0x0A, and the
typed parse/build pair lives with the policy rather than in the codec:
parse_acl turns a decoded ACL TLV into ace_t values, encode_acl turns them
back into bytes. Keeping them here is why an ACE test needs no hand-rolled byte
builder.
Parse-time validation is policy-gated: under the ALLOW-only profile a DENY
entry or an unrecognized flag is rejected with TYPE_MISMATCH at write time, so
stored entries never carry a semantic the running policy cannot evaluate.
Parsing an ACL is strict, and that is a security property, not fussiness
(#906). A lenient read of
an access-control document does not lose a field — it changes what the document
grants. A type sent as a big-endian u16 0x0001 (DENY) has 0x00 in its low
byte, so a width-tolerant load turned a refusal into a grant; a dropped
expires_ns turned a time-limited grant permanent; an ignored unknown key
dropped whatever restriction a newer writer meant to add. So parse_acl rejects
a numeric field whose payload is empty or wider than the field, a known key
whose value TLV is the wrong type, an unknown key, a repeated key, and a body
whose (NAME key, value) pairing does not hold. A payload narrower than the
field is fine: little-endian zero-extension names the same integer, which is why
a two-byte access_mask — the acl/acl-aces vector’s spelling before RFC-0026
canonicalized the field at u32 — keeps parsing.
The walk is pair-consuming, the same mechanics wire::config_reader_t uses — and
the exact opposite ruling on unknown keys, deliberately. Config is where a newer
peer legitimately sends more than the receiver understands; an ACL is not.
Pitfalls¶
NO_MATCHis notDENY. A policy that collapses the two takes the open-by-default decision away from the caller and changes behaviour on every vertex with no applicable entry.Expiry is absolute, in nanoseconds since the UNIX epoch. The clock is passed in by the caller; a policy that read a clock itself could not be tested deterministically and would disagree with the rest of one check.
Inheritance is a flag, not a mode. Only
kAceInherit-flagged entries of an ancestor reach a descendant; an entry without it is local no matter where it sits in the tree.The subject token is opaque. Comparing it is a byte comparison; the protocol never parses it, so an implementation that reads structure into it has invented a private extension.
A resolver may not return
EVERYONE@. It is the one string carved out of the otherwise opaque token space, and a resolver that hands it back names no principal — the core refuses that caller instead of letting an identity impersonate the wildcard.
API reference¶
-
struct ace_t¶
One parsed ACE of a vertex’s
:acl(ADR-0020 / #81).Evaluation is the pure per-target policy of ADR-0050 (
security_acl.hpp): the default ALLOW-only MCU profile rejects a DENY ACE (or any flag bit beyondkAceInherit) at write time with TYPE_MISMATCH, so stored ACEs never carry semantics the selected evaluator would silently weaken; the fullsecurity_aclhost policy (LIBTRACER_ACL_FULL) stores DENY and evaluates ordered first-match-per-bit.Public Members
-
ace_type_t type = ace_type_t::ALLOW¶
ALLOW or DENY (policy-gated at parse).
-
std::uint8_t flags = 0¶
ACE flags; only
kAceInheritis accepted.
-
std::vector<std::byte> subject¶
Opaque subject token (ADR-0018); the special subject
"EVERYONE@"matches any resolved subject.
-
std::uint32_t access_mask = 0¶
Granted rights (an OR of
acl_right_tbits).
-
std::uint64_t expires_ns = 0¶
Absolute expiry, ns since the UNIX epoch; 0 = never expires. An expired ACE grants nothing.
-
ace_type_t type = ace_type_t::ALLOW¶
-
enum class tr::graph::ace_type_t : std::uint8_t¶
An ACE’s type (ADR-0020): ALLOW grants; DENY refuses (full policy only).
Values:
-
enumerator ALLOW¶
The ACE grants its mask’s rights.
-
enumerator DENY¶
The ACE refuses them — evaluated only by
full_acl_policy_t(ADR-0050); the ALLOW-only profile rejects DENY at parse time.
-
enumerator ALLOW¶
-
enum class tr::graph::acl_right_t : std::uint32_t¶
One right bit of an ACE
access_mask(docs/reference/05 §0x0A, ADR-0020).Single-bit values so a gate tests exactly one right; a stored mask may carry any OR of them.
WRITE_ACLis precisely theadminright (modify the ACL / delegate).Values:
-
enumerator READ¶
Read the vertex value / control fields.
-
enumerator WRITE¶
Write the vertex value / control fields (fan-in gate).
-
enumerator SUBSCRIBE¶
Append a
:subscribers[]edge (fan-out gate).
-
enumerator CREATE¶
Create a child via
:children[](ADR-0017).
-
enumerator DELETE¶
Remove a child (reserved; no core surface yet).
-
enumerator READ_ACL¶
Read the
:aclfield.
-
enumerator WRITE_ACL¶
Modify the
:aclfield — theadminright.
-
enumerator WRITE_OWNER¶
Transfer ownership (reserved; no core surface yet).
-
enumerator READ¶
-
constexpr std::uint8_t tr::graph::kAceInherit = 0x1¶
The one ACE flag the core subset honors: propagate to the subtree (ADR-0020).
-
constexpr std::string_view tr::graph::kEveryoneSubject = "EVERYONE@"¶
The wildcard subject spelling (ADR-0020): an ACE carrying exactly these bytes as its subject applies to every resolved subject.
It is RESERVED, not merely magic (#908). The wire has one spelling for a subject token — the
acl/acl-acesconformance vector sendspeer-aand this string as the same opaque VALUE — so a principal that could BE these bytes would be indistinguishable from the wildcard, and the deployments at risk are exactly the ones whose resolver passes a caller-supplied identity through (usernames, cert CNs, peer names). The core therefore refuses to let a resolved subject spell it (see is_reserved_subject), rather than leaving every integrator to know to blacklist it.Note
This is the ONLY special subject. ADR-0020 originally named
OWNER@alongside it, but no evaluator here ever special-cased that string, so anOWNER@ACE matched nobody — and since any present ACE closes an otherwise-open vertex, such an ACE LOCKED the vertex it was written to delegate. ADR-0020’s erratum (#1033) withdraws the name rather than reserving it: with no document telling an operator to write that ACE, there is nothing for an impersonatedOWNER@principal to match either. It is an ordinary opaque token. Real owner semantics need a per-vertex owner identity the graph does not hold and would change how a STORED ACE evaluates — an amendment, not this.
-
inline bool tr::graph::is_reserved_subject(std::span<const std::byte> subject) noexcept¶
True iff
subjectspells a RESERVED subject token — today exactly kEveryoneSubject — and so may never be a resolved principal (#908).Checked in the two places a subject reaches an ACE comparison in
core/: once per policy evaluation (allow_only_policy_t::allows/full_acl_policy_t::allows— between them the only callers ofdetail_acl::ace_applies, and whateffective_acl_t::allowsdrives), where a reserved subject matches nothing; and once per resolver return ingraph_t::acl_allows— the only site that invokes a resolver — which is decisive: that caller is refused at every gate, like the resolver’s own error arm (#905). Public so an integrator’s resolver can reject the token at its own door too.
-
enum class tr::graph::acl_verdict_t : std::uint8_t¶
A pure policy’s answer for one ACE list (ADR-0050).
NO_MATCHmeans no applicable ACE decided the bit — the caller keeps walking (ancestor lists) and finally applies the open-by-default rule itself.Values:
-
enumerator ALLOW¶
A matching ACE grants the right.
-
enumerator DENY¶
A matching DENY ACE refuses it (full policy only).
-
enumerator NO_MATCH¶
No applicable ACE decided — keep walking / default.
-
enumerator ALLOW¶
-
class effective_acl_t¶
One vertex’s EFFECTIVE ACL (ADR-0020/0050): its own ACEs plus the INHERIT-flagged ancestor ACEs, pre-merged in evaluation order.
The pure owner of the effective-ACL semantics that previously lived inline in
graph_t::acl_allows: build the merged list with append_own (the target’s ACEs, stored order) followed by one append_ancestor per ancestor, NEAREST-FIRST (which filters tokAceInherit— a non-INHERIT ancestor ACE applies to that vertex only), then evaluate with allows. Pure like the policies it drives: no graph access, no locks, no clock reads of its own — unit-testable with synthetic ACE lists.The merged list is what the graph caches per vertex (the ADR-0050 cached effective-ACE merge): only the MERGE is cached, never a verdict —
expires_nsis evaluated against the caller’snowat check time, so expiry needs no invalidation.Public Functions
-
inline void append_own(std::span<const ace_t> aces)¶
Append the target vertex’s own ACEs (all of them, stored order).
Note
Call BEFORE any append_ancestor — own ACEs evaluate first (the effective-ACL ordering of ADR-0020).
-
inline void append_ancestor(std::span<const ace_t> aces)¶
Append one ancestor’s ACEs, keeping only the
kAceInherit-flagged ones (a non-INHERIT ACE applies to that vertex only, ADR-0020).Call once per strict ancestor, NEAREST-FIRST, so the full policy’s first-match-per-bit ordering follows the effective-ACL definition.
-
inline const std::vector<ace_t> &merged() const noexcept¶
The merged effective-ACE list, in evaluation order.
-
inline std::vector<ace_t> release() noexcept¶
Move the merged list out (what the graph stores in its per-vertex cache).
-
template<class Policy = acl_policy_t>
inline bool allows(std::span<const std::byte> subject, std::uint32_t bit, std::uint64_t now) const noexcept¶ The final ACL verdict over THIS instance’s merged list (the static allows over merged).
Public Static Functions
-
template<class Policy = acl_policy_t>
static inline bool allows(std::span<const ace_t> merged, std::span<const std::byte> subject, std::uint32_t bit, std::uint64_t now, std::uint8_t required_flags = 0) noexcept¶ The final ACL verdict over a pre-merged effective-ACE list.
Hands
mergedto the purePolicyONCE (the merge already applied the per-listrequired_flagsfiltering and the own-before-ancestors order, so one pass is verdict-identical to the per-list walk) and applies the open-by-default rule: no effective ACE at all ⇒ allowed (enforcement is opt-in via ACL presence); ANY present ACE — even an expired one — closes the vertex, soNO_MATCHover a non-empty list denies.- Parameters:
merged – A list built by append_own / append_ancestor (or this instance’s merged, via the member overload).
subject – The resolved subject token bytes (ADR-0018).
bit – The requested right (one
acl_right_tbit).now – Check-time wall clock, ns since the UNIX epoch.
required_flags – ACEs lacking these bits are skipped, exactly as
Policy::allowsskips them:0evaluates the whole list (the bearer’s own check),kAceInheritevaluates the inheritable subsequence — what a BARE descendant sees. Filtering in place rather than against a pre-projected copy is what lets the merge be stored once; it is order-identical by construction, since skipping elements cannot reorder the ones that remain, which matters because the full policy is first-match-per-bit in stored order.
- Returns:
true iff
subjectmay exercisebit.
-
inline void append_own(std::span<const ace_t> aces)¶
-
template<class Policy = acl_policy_t>
result_t<std::vector<ace_t>> tr::graph::parse_acl(const wire::tlv_t &acl)¶ Parse a decoded
:aclACL TLV into typed ACEs (docs/reference/05 §0x0A).STRICT by construction, because an ACL is a security document: a shape the builder never emits is rejected with
TYPE_MISMATCHat write time rather than read leniently, since leniency here does not lose a field — it INVERTS or WIDENS a grant (#906). Rejected, per ACE:a DENY ACE under a policy that cannot evaluate one (
Policy::kAcceptsDeny), and any flag bit beyondkAceInherit— the inheritance-only subset both adapters honor today; richer NFSv4 flags gate on the graph’s merge honoring them first;a missing
type/subject/access_mask, or an emptysubjecttoken;a numeric field whose payload is empty or wider than the field (
detail_acl::ace_field_ok—type/flagsu8,access_masku32,expires_nsu64), which is where a big-endian u16typeof0x0001used to truncate from DENY to ALLOW;a KNOWN key carrying the wrong value TLV type — rejected, never skipped: a dropped
expires_nsturns a time-limited grant permanent;an UNKNOWN key, a repeated key, a non-
NAMEchild in a key slot, and an odd child count (a key with no value, or a value with no key).
The walk is pair-consuming, the mechanics of
wire::config_reader_t(#927): it steps one whole(NAME key, value)pair at a time, so a value can never be re-read as the next key — which asubjectsent as aNAME(a spelling this function accepts, forEVERYONE@) previously could be. The unknown-key ruling is the OPPOSITE of that reader’s, deliberately: config is where a newer peer legitimately sends more than the receiver understands, so it skips the pair; an ACL is not, so a silently dropped attribute would widen access.- Template Parameters:
Policy – The accepting policy (defaults to the target’s selection).
- Parameters:
acl – A decoded ACL wire::tlv_t (
ACL{ ACL{NAME/VALUE…}* }).- Returns:
The typed ACE list, in wire order, or
TYPE_MISMATCH.
-
inline std::vector<std::byte> tr::graph::encode_acl(std::span<const ace_t> aces)¶
Encode typed ACEs as the wire
ACL{ ACL{…}* }TLV bytes — the typed builder (the inverse of parse_acl; kills per-test byte builders).Emits NAME-tagged
type(u8) /flags(u8) /subject(opaque VALUE) /access_mask(u32) children, plusexpires_ns(u64) when non-zero, per docs/reference/05 §0x0A. Encoding is unvalidated by design (tests build deliberately-rejectable ACLs with it); parse_acl is the gate.
The two selectable policies are documented with the rest of the build configuration, on config.
See: graph (which owns the effective-ACE walk), config (which selects the policy), reference §protocol TLVs (the wire layout), reference §host embedding.