Effective ACL = own ACEs + INHERIT-flagged ancestor ACEs (L4 auth / ACL)

:acl is not written per leaf, the way :subscribers[] is not. An ACE on a composite carries the kAceInherit flag and applies to that composite’s whole subtree, so an owner grants an orchestrator admin over /dev once instead of over every endpoint below it — NFSv4 inheritance riding the address composition (ADR-0020).

What to notice

  • Inheritance is per ACE, not per list. One :acl holds both kinds side by side, and the example’s composite does: a flagged READ that covers the subtree, and an unflagged WRITE that applies to /dev and nowhere else.

  • It covers the subtree, not the child. The flagged ACE reaches /dev/temp/raw as readily as /dev/temp. The walk is over strict ancestors, nearest first.

  • An inherited grant is still one bit. An inherited READ is not a WRITE; nothing widens on the way down.

  • Own ACEs come first, and combine rather than replace. Writing an :acl on the child does not shadow what it inherits — the example’s app gains WRITE while fleet keeps its inherited READ. Ordering (own before ancestors) only matters under the full policy, but the merge is built that way regardless (ADR-0050).

  • The result worth internalising: a closed parent can sit over an open child. A descendant whose only candidate ACE is an ancestor’s unflagged one has an empty effective ACL, and an empty effective ACL is open. The example writes an unflagged ACE on /site, watches a stranger be refused there — and watches the same stranger write /site/leaf. If you meant to protect the subtree, the flag is not optional.

  • Only the merge is cached, never a verdict. An :acl write marks the subtree dirty and the next check rebuilds, so a revoked grant takes effect on the very next operation — and expiry, which is evaluated against the caller’s clock, needs no invalidation at all (expiry).

  • Nothing here is conditional — the target builds and runs under every CI leg.

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — a vertex's EFFECTIVE ACL is its own ACEs plus the INHERIT-flagged
  9 *        ancestor ones.
 10 *
 11 * `:acl` is not written per leaf, the way `:subscribers[]` is not: an ACE on a composite carries
 12 * the `kAceInherit` flag and applies to that composite's whole subtree, so an owner grants an
 13 * orchestrator admin over `/dev` once instead of over every endpoint below it (ADR-0020, NFSv4
 14 * inheritance riding the address composition).
 15 *
 16 * Inheritance is PER ACE, not per list. One `:acl` can hold both kinds side by side, and the
 17 * unflagged ones apply to the vertex they were written to and nowhere else. That asymmetry is
 18 * the whole example, and it produces the one result worth internalising: a descendant whose only
 19 * candidate ACE is an ancestor's UNFLAGGED one has an EMPTY effective ACL, and an empty effective
 20 * ACL is open — so the parent is closed while the child beneath it is not.
 21 *
 22 * Nothing here is cached as a verdict. An `:acl` write marks the subtree dirty and the next
 23 * check rebuilds the merge, so a revoked grant takes effect on the very next operation.
 24 *
 25 * Runs under ctest as `example_acl_inherit`; returns non-zero on any failed check.
 26 */
 27
 28#include <cstdio>
 29#include <cstring>
 30#include <span>
 31#include <string_view>
 32#include <vector>
 33
 34#include "libtracer/graph.hpp"
 35#include "libtracer/mem_heap.hpp"
 36#include "libtracer/security_acl.hpp"
 37
 38namespace {
 39
 40using tr::graph::ace_t;
 41using tr::graph::acl_right_t;
 42using tr::graph::graph_t;
 43using tr::graph::kAceInherit;
 44using tr::graph::path_t;
 45using tr::graph::role_t;
 46using tr::graph::status_t;
 47using tr::graph::subject_token_t;
 48using tr::graph::vertex_handle_t;
 49
 50/** @brief Report expectation @p what and record a failure on @p ok. */
 51void check(bool& ok, bool cond, const char* what) {
 52    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 53    ok = ok && cond;
 54}
 55
 56/** @brief @p s as opaque subject-token bytes. */
 57std::vector<std::byte> as_bytes(std::string_view s) {
 58    std::vector<std::byte> out(s.size());
 59    std::memcpy(out.data(), s.data(), s.size());
 60    return out;
 61}
 62
 63/** @brief The caller context IS the subject token. */
 64std::expected<subject_token_t, tr::wire::err_t> caller_is_subject(void*, std::string_view caller) {
 65    return as_bytes(caller);
 66}
 67
 68/** @brief @p right as the single `access_mask` bit it is. */
 69constexpr std::uint32_t bit(acl_right_t right) { return static_cast<std::uint32_t>(right); }
 70
 71/** @brief A one-byte VALUE. */
 72tr::view::view_t some_value() {
 73    const std::byte one[1] = {std::byte{0x01}};
 74    return *tr::view::over_bytes(one);
 75}
 76
 77/** @brief True iff @p r was refused by an ACL gate. */
 78template <class T>
 79bool denied(const tr::graph::result_t<T>& r) {
 80    return !r.has_value() && r.error() == status_t::PERMISSION_DENIED;
 81}
 82
 83}  // namespace
 84
 85int main() {
 86    bool ok = true;
 87    graph_t g;
 88    g.configure_subject_resolver(caller_is_subject, nullptr);
 89
 90    const vertex_handle_t dev = g.register_vertex(path_t("/dev"), role_t::STORED_VALUE);
 91    const vertex_handle_t temp = g.register_vertex(path_t("/dev/temp"), role_t::STORED_VALUE);
 92    const vertex_handle_t raw = g.register_vertex(path_t("/dev/temp/raw"), role_t::STORED_VALUE);
 93    for (vertex_handle_t v : {dev, temp, raw}) (void)g.write(v, some_value());  // trusted seeds
 94
 95    // One :acl on the composite, carrying both kinds of ACE.
 96    const ace_t composite[] = {
 97        {.flags = kAceInherit, .subject = as_bytes("fleet"), .access_mask = bit(acl_right_t::READ)},
 98        {.subject = as_bytes("local"), .access_mask = bit(acl_right_t::WRITE)},
 99    };
100    (void)g.write(path_t("/dev:acl"), *tr::view::over_bytes(tr::graph::encode_acl(composite)));
101
102    // The flagged ACE reaches the whole subtree, at any depth.
103    check(ok, g.read(temp, "fleet").has_value(), "the INHERIT ACE grants READ one level down");
104    check(ok, g.read(raw, "fleet").has_value(), "…and two: it covers the subtree, not the child");
105    check(ok, denied(g.write(temp, some_value(), "fleet")),
106          "…and it inherits ONE bit — an inherited READ is not a WRITE");
107
108    // The unflagged one applies where it was written, and stops there.
109    check(ok, g.write(dev, some_value(), "local").has_value(),
110          "the unflagged ACE grants WRITE on /dev itself");
111    check(ok, denied(g.write(temp, some_value(), "local")),
112          "…and does not travel: /dev/temp is closed by the inherited ACE, which does not name it");
113
114    // Own ACEs come first, and combine with what is inherited rather than replacing it.
115    const ace_t own[] = {{.subject = as_bytes("app"), .access_mask = bit(acl_right_t::WRITE)}};
116    (void)g.write(path_t("/dev/temp:acl"), *tr::view::over_bytes(tr::graph::encode_acl(own)));
117    check(ok, g.write(temp, some_value(), "app").has_value(), "the child's own ACE grants WRITE");
118    check(ok, g.read(temp, "fleet").has_value(),
119          "…and the inherited READ still applies alongside it");
120
121    // The consequence: an unflagged ancestor ACE leaves its descendants with NOTHING to
122    // evaluate, and nothing to evaluate is open. A closed parent over an open child.
123    const vertex_handle_t site = g.register_vertex(path_t("/site"), role_t::STORED_VALUE);
124    const vertex_handle_t leaf = g.register_vertex(path_t("/site/leaf"), role_t::STORED_VALUE);
125    (void)g.write(site, some_value());
126    (void)g.write(leaf, some_value());
127    const ace_t unflagged[] = {
128        {.subject = as_bytes("someone"), .access_mask = bit(acl_right_t::READ)}};
129    (void)g.write(path_t("/site:acl"), *tr::view::over_bytes(tr::graph::encode_acl(unflagged)));
130    check(ok, denied(g.write(site, some_value(), "stranger")),
131          "the ACE closes /site to a stranger");
132    check(ok, g.write(leaf, some_value(), "stranger").has_value(),
133          "…and /site/leaf stays OPEN: an unflagged ancestor ACE is not in its effective ACL");
134
135    std::printf("effective ACL = own ACEs + ancestor ACEs carrying flag 0x%02x\n", kAceInherit);
136    return ok ? 0 : 1;
137}

See also: security-acl module · graph model · network formation · access_mask is a bitfield · the two policy profiles.