The caller context is not the subject (L4 auth / ACL)¶
An ACE names a subject token — opaque bytes. An operation arrives carrying a caller
context — this node’s own NAME for the inbound link the frame came in on. Those are different
things, and the pluggable subject_resolver_fn_t is the seam between them
(ADR-0018):
it is where an integrator turns “the frame came in on link ws:7” into “and that link belongs
to alice”.
That split is what lets the ACL model stay fixed while identity gets stronger. v1’s transport-authenticated peer id and a later raw-key ed25519 identity are both just tokens (ADR-0045); libtracer does authorization, and the transport does authentication.
What to notice¶
The ACE names the principal, not the link. The example’s ACL says
alice; nothing anywhere saysws:7. Only the resolver — integrator code — knows the two are connected, which is why a principal survives being dialled in on a different link tomorrow.The ERROR arm is a deny, not a fallback. “I cannot name this caller” — a stale link, a revoked peer, a lookup that failed — refuses at every gate. The predecessor of this signature returned
std::optional, whosenulloptmeant fully trusted, so an unresolvable caller used to be granted everything,WRITE_ACLincluded (#905). The example’s last check is that specific disaster: the unnameable caller cannot rewrite the:aclto grant itself access.The empty caller context never reaches the resolver. The example counts invocations through its
ctx: two local writes, zero calls. The trusted local channel is settled before the resolver, so a resolver author never has to have an opinion about it — and a remote op, which always carries a non-empty link NAME, cannot reach that arm.It is a
{fn, ctx}pair, not astd::function. Assigning astd::functiondestroys the old target, so a setter racing a gate freed the resolver’s captured state while a reader was inside the call. A bare function pointer is publishable in one word, so the pair lives in asink_slot_tand the gate reads a coherent snapshot (#1049). Whatever state the resolver needs travels inctx, which the caller owns and must keep alive across every gated operation.Install it at wiring time, from one thread, before frames flow.
configure_subject_resolveris configuration, which the verb says on purpose.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 — the caller context is not the subject; a RESOLVER maps one to the other.
9 *
10 * An ACE names a SUBJECT TOKEN — opaque bytes. An operation arrives carrying a CALLER CONTEXT —
11 * this node's own NAME for the inbound link the frame came in on. Those are different things,
12 * and the pluggable `subject_resolver_fn_t` is the seam between them (ADR-0018): it is where an
13 * integrator turns "the frame came in on link `ws:7`" into "and that link belongs to `alice`".
14 * Authorization is what libtracer does; deciding whose identity a link carries is the
15 * resolver's, which is why v1's transport-authenticated peer id and a later ed25519 key both
16 * fit the same ACL model without changing it.
17 *
18 * Three properties of the seam that a resolver author has to get right, all shown below:
19 *
20 * - The ERROR arm is a **deny**, not a fallback (#905). "I cannot name this caller" refuses at
21 * every gate. Its predecessor returned `std::optional`, whose `nullopt` meant FULLY TRUSTED —
22 * so an unresolvable caller used to be granted everything, `WRITE_ACL` included.
23 * - The EMPTY caller context never reaches the resolver at all. The graph settles it as the
24 * trusted local channel first, so the resolver is never asked to have an opinion about it.
25 * - It is a `{fn, ctx}` pair, not a `std::function` (#1049): whatever state the resolver needs
26 * travels in a caller-owned `ctx` that must outlive every gated operation.
27 *
28 * Runs under ctest as `example_acl_subject_resolver`; returns non-zero on any failed check.
29 */
30
31#include <cstdio>
32#include <cstring>
33#include <expected>
34#include <span>
35#include <string_view>
36#include <vector>
37
38#include "libtracer/graph.hpp"
39#include "libtracer/mem_heap.hpp"
40#include "libtracer/security_acl.hpp"
41
42namespace {
43
44using tr::graph::ace_t;
45using tr::graph::acl_right_t;
46using tr::graph::graph_t;
47using tr::graph::path_t;
48using tr::graph::role_t;
49using tr::graph::status_t;
50using tr::graph::subject_token_t;
51using tr::graph::vertex_handle_t;
52
53/** @brief Report expectation @p what and record a failure on @p ok. */
54void check(bool& ok, bool cond, const char* what) {
55 std::printf(" [%s] %s\n", cond ? "ok" : "FAIL", what);
56 ok = ok && cond;
57}
58
59/** @brief @p s as opaque token bytes. */
60std::vector<std::byte> as_bytes(std::string_view s) {
61 std::vector<std::byte> out(s.size());
62 std::memcpy(out.data(), s.data(), s.size());
63 return out;
64}
65
66/** @brief The resolver's own state — caller-owned, and the reason `ctx` exists. */
67struct link_directory_t {
68 /** @brief How many times the gate invoked the resolver — the empty caller never does. */
69 int invocations = 0;
70};
71
72/**
73 * @brief A resolver with a real mapping in it: link name in, principal out.
74 *
75 * `ws:7` is a link this node accepted; `alice` is who is behind it. An ACE names `alice`,
76 * because a principal outlives the link it happens to be dialled in on today.
77 */
78std::expected<subject_token_t, tr::wire::err_t> resolve_link_owner(void* ctx,
79 std::string_view caller) {
80 static_cast<link_directory_t*>(ctx)->invocations += 1;
81 if (caller == "ws:7") return as_bytes("alice");
82 if (caller == "ws:8") return as_bytes("bob");
83 // Every other link is one this node cannot put a name to: a stale link, a revoked peer,
84 // a lookup that failed. That is a DENY, and saying so is the whole point of the arm.
85 return std::unexpected(tr::wire::err_t::ACCESS_DENIED);
86}
87
88/** @brief One ALLOW ACE granting @p subject exactly @p right, as a writable `:acl` value. */
89tr::view::view_t one_grant(std::string_view subject, acl_right_t right) {
90 const ace_t ace{.subject = as_bytes(subject), .access_mask = static_cast<std::uint32_t>(right)};
91 const std::vector<std::byte> acl = tr::graph::encode_acl(std::span<const ace_t>(&ace, 1));
92 return *tr::view::over_bytes(acl);
93}
94
95/** @brief A one-byte VALUE — the payload every write below carries. */
96tr::view::view_t some_value() {
97 const std::byte one[1] = {std::byte{0x01}};
98 return *tr::view::over_bytes(one);
99}
100
101/** @brief True iff @p r was refused by an ACL gate. */
102template <class T>
103bool denied(const tr::graph::result_t<T>& r) {
104 return !r.has_value() && r.error() == status_t::PERMISSION_DENIED;
105}
106
107} // namespace
108
109int main() {
110 bool ok = true;
111 link_directory_t directory;
112
113 graph_t g;
114 g.configure_subject_resolver(resolve_link_owner, &directory);
115 const vertex_handle_t v = g.register_vertex(path_t("/dev/temp"), role_t::STORED_VALUE);
116 (void)g.write(v, some_value()); // the trusted local caller seeds a value…
117 (void)g.write(path_t("/dev/temp:acl"), one_grant("alice", acl_right_t::READ)); // …and the ACL
118
119 check(ok, directory.invocations == 0,
120 "two local writes, zero resolver calls — the empty context is settled first (#905)");
121
122 // The ACE says `alice`. Nothing anywhere says `ws:7`; the resolver is the only thing that
123 // knows the two are connected, and it is an integrator's code, not the library's.
124 check(ok, g.read(v, "ws:7").has_value(), "link ws:7 resolves to alice, who is granted READ");
125 check(ok, denied(g.read(v, "ws:8")), "link ws:8 resolves to bob, who is not");
126 check(ok, denied(g.read(v, "ws:9")),
127 "link ws:9 resolves to NOBODY — the error arm denies, it does not wave through");
128 check(ok, directory.invocations == 3, "three remote reads, three resolver calls");
129
130 // The deny arm is a deny at EVERY gate, control plane included — which is exactly what the
131 // std::optional predecessor got wrong: an unnameable caller could rewrite the ACL.
132 check(ok, denied(g.write(v, some_value(), "ws:9")), "…and the unnameable caller cannot WRITE");
133 check(ok,
134 denied(g.write(v, path_t::parse("/dev/temp:acl")->field(),
135 one_grant("ws:9", acl_right_t::READ), "ws:9")),
136 "…and above all cannot rewrite the :acl to grant itself the access");
137
138 std::printf("resolver invoked %d times; ws:7=alice, ws:8=bob, everything else denied\n",
139 directory.invocations);
140 return ok ? 0 : 1;
141}
See also: security-acl module · graph module · network formation · open by default · the reserved wildcard subject.