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_t is 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, or NO_MATCH. NO_MATCH means 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

allow_only_policy_t

the default, MCU-class

ALLOW entries only, with a single INHERIT flag; there is no ordering question to answer

full_acl_policy_t

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_MATCH is not DENY. 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 beyond kAceInherit) at write time with TYPE_MISMATCH, so stored ACEs never carry semantics the selected evaluator would silently weaken; the full security_acl host 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 kAceInherit is 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_t bits).

std::uint64_t expires_ns = 0

Absolute expiry, ns since the UNIX epoch; 0 = never expires. An expired ACE grants nothing.

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.

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_ACL is precisely the admin right (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 :acl field.

enumerator WRITE_ACL

Modify the :acl field — the admin right.

enumerator WRITE_OWNER

Transfer ownership (reserved; no core surface yet).

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-aces conformance vector sends peer-a and 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 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: with no document telling an operator to write that ACE, there is nothing for an impersonated OWNER@ 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 subject spells 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 of detail_acl::ace_applies, and what effective_acl_t::allows drives), where a reserved subject matches nothing; and once per resolver return in graph_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_MATCH means 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.

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 to kAceInherit — 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_ns is evaluated against the caller’s now at 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 merged to the pure Policy ONCE (the merge already applied the per-list required_flags filtering 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, so NO_MATCH over 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_t bit).

  • now – Check-time wall clock, ns since the UNIX epoch.

  • required_flags – ACEs lacking these bits are skipped, exactly as Policy::allows skips them: 0 evaluates the whole list (the bearer’s own check), kAceInherit evaluates 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 subject may exercise bit.

template<class Policy = acl_policy_t>
result_t<std::vector<ace_t>> tr::graph::parse_acl(const wire::tlv_t &acl)

Parse a decoded :acl ACL 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_MISMATCH at 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 beyond kAceInherit — 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 empty subject token;

  • a numeric field whose payload is empty or wider than the field (detail_acl::ace_field_oktype/flags u8, access_mask u32, expires_ns u64), which is where a big-endian u16 type of 0x0001 used to truncate from DENY to ALLOW;

  • a KNOWN key carrying the wrong value TLV type — rejected, never skipped: a dropped expires_ns turns a time-limited grant permanent;

  • an UNKNOWN key, a repeated key, a non-NAME child 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 a subject sent as a NAME (a spelling this function accepts, for EVERYONE@) 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, plus expires_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.