Open by default, and the first ACE is the lock (L4 auth / ACL)

A fresh graph_t has no subject resolver, and a fresh vertex has no :acl. Both are open states, and they are independent: installing a resolver does not close a bare vertex, and writing an :acl does not close a graph that cannot name its callers. Only the two together enforce anything (ADR-0018, ADR-0020).

What to notice

  • Enforcement is opt-in twice over, and each opt-in is separate. With no resolver the ACL is still parsed, still validated and still stored — it is simply never evaluated, because there is nothing to evaluate it against. With a resolver but no ACE, every vertex answers as it always did. The example shows each half failing to enforce on its own.

  • There is no “deny” to write. The first grant is the lock. An empty effective ACL is open; any present ACE closes the vertex to every subject that ACE does not name. That is the rule that surprises people: peer-a is refused although nothing anywhere mentions peer-a.

  • A grant is one bit, not a door. The subject the ACE does name is refused for every right the mask does not carry — the example’s peer-z may read and may not write. See access_mask is a bitfield.

  • The empty caller context is the trusted local channel. The in-process host API passes no caller, and that context is settled as trusted before the resolver is consulted at all (#905) — which is how the :acl in the example gets written in the first place. A remote operation always carries its inbound link NAME, so it cannot spell the trusted context.

  • This is why an ACL is not a deployment checklist item you can defer. A node that ships without a resolver is not “using default permissions”; it is not enforcing at all.

  • Nothing here is conditional — the target builds and runs under every CI leg, with no net plane, no sockets and no threads.

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 — enforcement is opt-in TWICE, and the first ACE is what closes a vertex.
  9 *
 10 * A `graph_t` ships with no subject resolver, and a vertex ships with no `:acl`. Both of those
 11 * are open states, and they are INDEPENDENT: installing a resolver does not close a bare
 12 * vertex, and writing an `:acl` does not close a graph that cannot name its callers. Only the
 13 * two together enforce anything (ADR-0018, ADR-0020, #81).
 14 *
 15 * The rule at the far end is the one that surprises people: an effective ACL that is EMPTY is
 16 * open, but any present ACE closes the vertex to every subject that ACE does not name. There is
 17 * no "deny" to write — the first grant is the lock.
 18 *
 19 * The empty caller context is the third open state and the only one that is a convention rather
 20 * than a configuration: the in-process host API passes no caller, and that channel is trusted
 21 * without consulting the resolver at all (#905). A remote operation always carries its inbound
 22 * link NAME, so it cannot spell the trusted context.
 23 *
 24 * Runs under ctest as `example_acl_open_by_default`; returns non-zero on any failed check.
 25 */
 26
 27#include <cstdio>
 28#include <cstring>
 29#include <span>
 30#include <string_view>
 31#include <vector>
 32
 33#include "libtracer/graph.hpp"
 34#include "libtracer/mem_heap.hpp"
 35#include "libtracer/security_acl.hpp"
 36
 37namespace {
 38
 39using tr::graph::ace_t;
 40using tr::graph::acl_right_t;
 41using tr::graph::graph_t;
 42using tr::graph::path_t;
 43using tr::graph::role_t;
 44using tr::graph::status_t;
 45using tr::graph::subject_token_t;
 46using tr::graph::vertex_handle_t;
 47
 48/** @brief Report expectation @p what and record a failure on @p ok. */
 49void check(bool& ok, bool cond, const char* what) {
 50    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 51    ok = ok && cond;
 52}
 53
 54/** @brief @p s as opaque subject-token bytes — a subject is bytes, never a string (ADR-0018). */
 55std::vector<std::byte> as_bytes(std::string_view s) {
 56    std::vector<std::byte> out(s.size());
 57    std::memcpy(out.data(), s.data(), s.size());
 58    return out;
 59}
 60
 61/** @brief The simplest resolver there is: the caller context IS the subject token. */
 62std::expected<subject_token_t, tr::wire::err_t> caller_is_subject(void*, std::string_view caller) {
 63    return as_bytes(caller);
 64}
 65
 66/** @brief One ALLOW ACE granting @p subject exactly @p right, as a writable `:acl` value. */
 67tr::view::view_t one_grant(std::string_view subject, acl_right_t right) {
 68    const ace_t ace{.subject = as_bytes(subject), .access_mask = static_cast<std::uint32_t>(right)};
 69    const std::vector<std::byte> acl = tr::graph::encode_acl(std::span<const ace_t>(&ace, 1));
 70    return *tr::view::over_bytes(acl);
 71}
 72
 73/** @brief A one-byte VALUE — the payload every write below carries. */
 74tr::view::view_t some_value() {
 75    const std::byte one[1] = {std::byte{0x01}};
 76    return *tr::view::over_bytes(one);
 77}
 78
 79}  // namespace
 80
 81int main() {
 82    bool ok = true;
 83
 84    // 1. No resolver. The ACL is STORED and enforced by nobody: the graph cannot turn a caller
 85    //    context into a subject, so there is nothing to match an ACE against.
 86    {
 87        graph_t g;
 88        const vertex_handle_t v = g.register_vertex(path_t("/x"), role_t::STORED_VALUE);
 89        (void)g.write(v, some_value());  // seed a value, so a refused READ is the only failure
 90        check(ok, g.write(path_t("/x:acl"), one_grant("peer-z", acl_right_t::READ)).has_value(),
 91              "the :acl is accepted and stored with no resolver installed");
 92        check(ok, g.read(v, "peer-a").has_value(),
 93              "…and peer-a still reads: with no resolver, enforcement is off entirely");
 94    }
 95
 96    // 2. A resolver, and a vertex nobody wrote an ACE to. Open, per vertex.
 97    {
 98        graph_t g;
 99        g.configure_subject_resolver(caller_is_subject, nullptr);
100        const vertex_handle_t v = g.register_vertex(path_t("/x"), role_t::STORED_VALUE);
101        check(ok, g.write(v, some_value(), "peer-a").has_value(),
102              "a resolver alone enforces nothing — a vertex with no ACE is open");
103    }
104
105    // 3. Both. Now the single ACE is the lock: it grants peer-z READ and, by existing at all,
106    //    refuses everyone else — including for rights it never mentions.
107    {
108        graph_t g;
109        g.configure_subject_resolver(caller_is_subject, nullptr);
110        const vertex_handle_t v = g.register_vertex(path_t("/x"), role_t::STORED_VALUE);
111        (void)g.write(v, some_value());  // seed a value as the trusted local caller
112        (void)g.write(path_t("/x:acl"), one_grant("peer-z", acl_right_t::READ));
113
114        check(ok, g.read(v, "peer-z").has_value(), "peer-z reads — the ACE names it");
115        const auto denied = g.read(v, "peer-a");
116        check(ok, !denied && denied.error() == status_t::PERMISSION_DENIED,
117              "peer-a is refused, and no ACE ever said so — presence is what closes");
118        const auto no_write = g.write(v, some_value(), "peer-z");
119        check(ok, !no_write && no_write.error() == status_t::PERMISSION_DENIED,
120              "even peer-z cannot WRITE: the grant is one bit, not a door");
121
122        // 4. And the local host API is still trusted, which is how the ACL got written above.
123        check(ok, g.write(v, some_value()).has_value(),
124              "the empty caller context is the trusted local channel (#905)");
125    }
126
127    std::printf("open by default: no resolver, no ACE, or no caller — any one of them opens\n");
128    return ok ? 0 : 1;
129}

See also: security-acl module · graph module · network formation · the subject resolver · access_mask is a bitfield.