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-ais refused although nothing anywhere mentionspeer-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-zmay read and may not write. Seeaccess_maskis 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
:aclin 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.