Two evaluators, and DENY exists only where one can see it (L4 auth / ACL)

The wire layout is the full NFSv4-style model; the required-modules MCU profile enforces a subset of it (ADR-0020). ACE evaluation is therefore a pure per-target policy (ADR-0050) with two adapters, both always compiled:

policy

rule

kAcceptsDeny

allow_only_policy_t (default)

any applicable ACE grants; order is irrelevant

false

full_acl_policy_t

ordered first-match-per-bit; the first applicable ACE decides by its type

true

What to notice

  • The same two ACEs mean the opposite thing under the two evaluators. Hand both a DENY followed by an ALLOW, same subject and same bit: the full policy answers DENY, the ALLOW-only policy answers ALLOW — because it never looks at the type at all.

  • Which is precisely why parse_acl refuses to store a DENY under the ALLOW-only profile. Not a limitation to work around: a stored restriction the running evaluator cannot see would be read as a grant, and a security document must never be interpreted more broadly than it was written. kAcceptsDeny is the constant that decides it, and the parser is its only reader.

  • Stored order is semantic under the full policy, and meaningless under the other. Reversing the two ACEs reverses the full policy’s verdict. An ALLOW-only list has nothing to order, which is exactly what lets the MCU subset skip the whole concept.

  • effective_acl_t is where the policy is chosen, and it adds the open-by-default rule on top: no effective ACE ⇒ allowed; any present ACE ⇒ NO_MATCH denies.

  • Both arms run in every build. The policies are named explicitly as template arguments rather than left to the target’s binding, so this example is not a run-time skip: the ALLOW-only and the full policy are both exercised whatever the target selected. The bound choice — acl_policy_t, plain C++ in config.hpp and rebindable by a config_override.hpp fragment (ADR-0068) — is only printed, on the last line.

  • Nothing here is conditional — the target builds and runs under every CI leg, and it touches no graph at all: the policies are pure functions over ACE lists.

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 — two evaluators, and DENY exists only where one of them can see it.
  9 *
 10 * The wire layout is the full NFSv4-style model; the required-modules MCU profile enforces a
 11 * SUBSET of it (ADR-0020). ACE evaluation is therefore a pure per-target policy (ADR-0050) with
 12 * two adapters, both always compiled:
 13 *
 14 *  - `allow_only_policy_t` — the default. Any applicable ACE grants. Order is irrelevant,
 15 *    because DENY does not exist in this profile.
 16 *  - `full_acl_policy_t` — ordered first-match-per-bit: the FIRST applicable ACE decides, by its
 17 *    own type. This is the host profile, selected per target.
 18 *
 19 * Hand both the SAME two-ACE list — a DENY followed by an ALLOW for the same subject and the
 20 * same bit — and they answer the opposite thing. That is not a quirk to work around; it is the
 21 * reason `parse_acl` REFUSES to store a DENY ACE under the ALLOW-only profile. A stored DENY
 22 * that the running evaluator cannot see would be read as a grant, and a security document must
 23 * never be interpreted more broadly than it was written.
 24 *
 25 * Both policies are named EXPLICITLY below, as template arguments, so this example demonstrates
 26 * both arms in every build regardless of which one the target binds. The bound choice is
 27 * `tr::graph::acl_policy_t` (ADR-0068 — plain C++ in `config.hpp`, rebindable by a
 28 * `config_override.hpp` fragment); it is printed for information and nothing here is skipped
 29 * because of it.
 30 *
 31 * Runs under ctest as `example_acl_policy_profiles`; returns non-zero on any failed check.
 32 */
 33
 34#include <cstdint>
 35#include <cstdio>
 36#include <cstring>
 37#include <span>
 38#include <string_view>
 39#include <vector>
 40
 41#include "libtracer/security_acl.hpp"
 42
 43namespace {
 44
 45using tr::graph::ace_t;
 46using tr::graph::ace_type_t;
 47using tr::graph::acl_right_t;
 48using tr::graph::acl_verdict_t;
 49using tr::graph::allow_only_policy_t;
 50using tr::graph::effective_acl_t;
 51using tr::graph::full_acl_policy_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 subject-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 @p right as the single `access_mask` bit it is. */
 67constexpr std::uint32_t bit(acl_right_t right) { return static_cast<std::uint32_t>(right); }
 68
 69/** @brief Check time — every ACE below is permanent, so the clock is not a variable here. */
 70constexpr std::uint64_t kNow = 1'800'000'000'000'000'000ULL;
 71
 72}  // namespace
 73
 74int main() {
 75    bool ok = true;
 76    const std::vector<std::byte> alice = as_bytes("alice");
 77    const std::uint32_t read = bit(acl_right_t::READ);
 78
 79    // The discriminating list: refuse alice, then grant her. Same subject, same bit.
 80    const ace_t deny_then_allow[] = {
 81        {.type = ace_type_t::DENY, .subject = as_bytes("alice"), .access_mask = read},
 82        {.type = ace_type_t::ALLOW, .subject = as_bytes("alice"), .access_mask = read},
 83    };
 84    check(ok, full_acl_policy_t::allows(alice, read, deny_then_allow, kNow) == acl_verdict_t::DENY,
 85          "full policy: the FIRST applicable ACE decides, and it is a DENY");
 86    check(ok,
 87          allow_only_policy_t::allows(alice, read, deny_then_allow, kNow) == acl_verdict_t::ALLOW,
 88          "ALLOW-only policy: it never looks at the type, so the same bytes GRANT");
 89
 90    // Which is exactly why that list may not be stored under the ALLOW-only profile. `parse_acl`
 91    // is where the refusal happens — the write door, not the evaluator (see acl_parse_strict).
 92    const auto encoded = tr::graph::encode_acl(deny_then_allow);
 93    const auto decoded = tr::wire::decode(encoded);
 94    check(ok, decoded.has_value(), "the ACL TLV itself is well-formed either way");
 95    check(ok, tr::graph::parse_acl<full_acl_policy_t>(*decoded).has_value(),
 96          "the full profile parses it: it can evaluate what it is about to store");
 97    const auto refused = tr::graph::parse_acl<allow_only_policy_t>(*decoded);
 98    check(ok, !refused && refused.error() == tr::graph::status_t::TYPE_MISMATCH,
 99          "the ALLOW-only profile refuses it with TYPE_MISMATCH, rather than storing a "
100          "restriction it would go on to read as a grant");
101    check(ok, !allow_only_policy_t::kAcceptsDeny && full_acl_policy_t::kAcceptsDeny,
102          "kAcceptsDeny is the constant that decides that, and parse_acl is its only reader");
103
104    // Order is a property of the full policy alone. Flip the two ACEs and it flips its answer;
105    // an ALLOW-only list has nothing to order, which is why the MCU subset can skip the concept.
106    const ace_t allow_then_deny[] = {deny_then_allow[1], deny_then_allow[0]};
107    check(ok, full_acl_policy_t::allows(alice, read, allow_then_deny, kNow) == acl_verdict_t::ALLOW,
108          "full policy: reversing the two ACEs reverses the verdict — stored order is semantic");
109
110    // Above the policies, `effective_acl_t` adds the open-by-default rule and is where the
111    // policy is chosen. Same merged list, both arms, in one build.
112    effective_acl_t merged;
113    merged.append_own(deny_then_allow);
114    check(ok, !effective_acl_t::allows<full_acl_policy_t>(merged.merged(), alice, read, kNow),
115          "through effective_acl_t, the full profile still denies alice");
116    check(ok, effective_acl_t::allows<allow_only_policy_t>(merged.merged(), alice, read, kNow),
117          "…and the ALLOW-only profile still grants her");
118    check(ok,
119          !effective_acl_t::allows<full_acl_policy_t>(merged.merged(), as_bytes("bob"), read, kNow),
120          "and neither profile lets bob in: no ACE names him, and the list is not empty");
121
122    std::printf("this build binds acl_policy_t with kAcceptsDeny=%s; both arms ran anyway\n",
123                tr::graph::acl_policy_t::kAcceptsDeny ? "true (full)" : "false (ALLOW-only)");
124    return ok ? 0 : 1;
125}

See also: security-acl module · config module · deployment profiles · strict ACL parsing · inheritance.