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 says ws: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, whose nullopt meant fully trusted, so an unresolvable caller used to be granted everything, WRITE_ACL included (#905). The example’s last check is that specific disaster: the unnameable caller cannot rewrite the :acl to 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 a std::function. Assigning a std::function destroys 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 a sink_slot_t and the gate reads a coherent snapshot (#1049). Whatever state the resolver needs travels in ctx, 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_resolver is 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.