|
| 1 | +<!-- |
| 2 | +SPDX-FileCopyrightText: Sudo Apt Holdings LLC |
| 3 | +SPDX-License-Identifier: Apache-2.0 |
| 4 | +--> |
| 5 | +# Writing an authority adapter |
| 6 | + |
| 7 | +How to make an external authority layer the one that decides for this node. |
| 8 | + |
| 9 | +**Every claim here is a path in this repository, or says NOT IN TREE.** A sentence you cannot follow |
| 10 | +to a file is a sentence you should not trust. |
| 11 | + |
| 12 | +## The split |
| 13 | + |
| 14 | +> Anything may propose. The authority in force authorizes. After an allow, this node's Elixir runs |
| 15 | +> the tool. |
| 16 | +
|
| 17 | +The adapter **decides**. It does not run the tool, and it does not change what it was asked about. |
| 18 | + |
| 19 | +## One module, chosen once, at boot |
| 20 | + |
| 21 | +`TRINITY_AUTHORITY` names one module. It is read once, by `Trinity.Authority.Selection` |
| 22 | +(`lib/trinity/authority/selection.ex`), as the first child of the application after the data |
| 23 | +directory lock, and the selection is kept in `:persistent_term`. No runtime path changes it. |
| 24 | + |
| 25 | +- unset, `""`, or `local` selects `Trinity.Authority.Local`. |
| 26 | +- any other value is an Elixir module name, with or without the `Elixir.` prefix. |
| 27 | +- the module must be **loaded** and must export every callback, or the boot stops and the refusal |
| 28 | + names which: `{:not_loaded, name}` or `{:missing_callback, module, {name, arity}}`. |
| 29 | +- a name that is not an existing atom is reported as not loaded, and no atom is created from the |
| 30 | + environment's text. |
| 31 | + |
| 32 | +Selection is **synchronous**: `Selection.start/0` completes before any later child starts, so there |
| 33 | +is no window in which the node is running with the selection still being made. Before that, |
| 34 | +`Trinity.Authority.impl/0` (`lib/trinity/authority.ex`) answers `Local` under `:default` and |
| 35 | +**raises** under `:regulated`, because a brief fallback to the local authority is still a fallback. |
| 36 | + |
| 37 | +`Trinity.Authority.Local` (`lib/trinity/authority/local.ex`) is **the only implementation in this |
| 38 | +tree**. Everything else is NOT IN TREE: this repository holds no client, no envelope, no schema and |
| 39 | +no transport for any particular authority layer, and a test asserts that under `local` no adapter |
| 40 | +module is loaded (`test/trinity/authority/selection_test.exs`). |
| 41 | + |
| 42 | +## The regulated profile refuses the local authority |
| 43 | + |
| 44 | +Under `TRINITY_PROFILE=regulated` the node refuses to boot on `Trinity.Authority.Local`: |
| 45 | + |
| 46 | +- the rule is `Trinity.Profile.check_authority/2` in `lib/trinity/profile.ex`, which answers |
| 47 | + `{:error, :regulated_refuses_local_authority}`. |
| 48 | +- it is asked at boot, before any child exists, by `verify_regulated_configuration!/0` in |
| 49 | + `lib/trinity/application.ex`. |
| 50 | +- an unset `TRINITY_AUTHORITY` resolves to `Local` and is therefore refused too. |
| 51 | +- a name that cannot be resolved is refused as `{:regulated_authority_unselectable, reason}`, |
| 52 | + carrying the selection's own reason, and `check_authority/2` is total, so a caller that loses the |
| 53 | + reason still gets `{:regulated_authority_unresolved, value}` rather than a crash. |
| 54 | + |
| 55 | +The refusals are tested in `test/trinity/profile_test.exs`, and at an actual boot, in spawned OS |
| 56 | +processes, in `test/trinity/regulated_boot_node_test.exs`. |
| 57 | + |
| 58 | +## The callbacks |
| 59 | + |
| 60 | +Four are required. `Trinity.Authority.callbacks/0` is the list the selection checks against, in |
| 61 | +`lib/trinity/authority.ex`. |
| 62 | + |
| 63 | +| callback | answer | what it is for | |
| 64 | +| --- | --- | --- | |
| 65 | +| `stage/2` | `{:ok, Staged.t()}` or `{:error, term()}` | record that an effect is about to happen | |
| 66 | +| `decide/3` | `{:ok, :allow \| :deny, basis}` or `{:error, term()}` | **the decision**, with a basis saying why | |
| 67 | +| `execute/3` | `{:error, :not_the_effect_path}` | reserved; see below | |
| 68 | +| `receipt/2` | `{:ok, term()}` or `{:error, term()}` | record a receipt of a kind | |
| 69 | + |
| 70 | +And one optional: |
| 71 | + |
| 72 | +| callback | answer | what it is for | |
| 73 | +| --- | --- | --- | |
| 74 | +| `forward_receipt/2` | `:ok` or `{:error, term()}` | acknowledge a queued receipt envelope | |
| 75 | + |
| 76 | +### `decide/3` returns allow or deny, and never falls back |
| 77 | + |
| 78 | +`:allow` or `:deny`. An adapter that cannot reach its far side returns `{:error, reason}` or |
| 79 | +`:deny`; it must **not** decide locally, and it must not answer as though the local gate had |
| 80 | +decided. The membrane treats `{:ok, :deny, basis}` as a denial with a receipt, and `{:error, _}` as |
| 81 | +a denial with a receipt. Both are safe. A fallback to `Local` is the one answer that is not. |
| 82 | + |
| 83 | +### `execute/3` must not run a tool |
| 84 | + |
| 85 | +It is **not** the place the effect happens, and nothing on the effect path calls it. After a |
| 86 | +`decide/3` allow and after the admission receipt is in the chain, `Trinity.Effects.run_tool/2` |
| 87 | +(`lib/trinity/effects.ex`) calls the tool's own `execute/2`, in this VM. |
| 88 | + |
| 89 | +It stays in the behaviour so that an adapter written against the older contract, where returning a |
| 90 | +result from it was how the effect happened, is not silently accepted. Answer |
| 91 | +`{:error, :not_the_effect_path}`, which is what `Trinity.Authority.Local` answers. |
| 92 | + |
| 93 | +### `stage/2` may not change the subject |
| 94 | + |
| 95 | +Set `staged_at` and `basis`. Nothing else. Every other field of the `Trinity.Authority.Staged` |
| 96 | +(`lib/trinity/authority/staged.ex`) you return must be the one you were given. |
| 97 | + |
| 98 | +After `stage/2` the membrane re-derives the fingerprint over the arguments you returned and compares |
| 99 | +it to the one the gate bound, not to the one your struct now carries, so rewriting `args` and |
| 100 | +`fingerprint` to agree with each other does not pass. It then compares every remaining field, |
| 101 | +because the fingerprint covers the session, the tool name, the arguments and the working directory, |
| 102 | +and covers `module` not at all: an adapter could otherwise leave every fingerprinted field alone and |
| 103 | +still change which code runs. |
| 104 | + |
| 105 | +Both refusals are tested against adapters that actually attempt them, in |
| 106 | +`test/trinity/effects/membrane_test.exs` with `Trinity.TestAuthority.MutatesArgs` and |
| 107 | +`Trinity.TestAuthority.SwapsModule` (`test/support/authority/adapters.ex`), alongside a control |
| 108 | +adapter that changes nothing and whose effect runs. |
| 109 | + |
| 110 | +## Receipts: Trinity writes them, the adapter acknowledges them |
| 111 | + |
| 112 | +Trinity signs and appends its own receipts, locally, around the effect: an `admit` before the tool |
| 113 | +runs and a `done` after it, or a `denied` instead. That is `Trinity.Effects` and |
| 114 | +`Trinity.Receipts`. **The adapter does not write Trinity's receipts.** |
| 115 | + |
| 116 | +What an adapter may do is receive them. `forward_receipt/2` is offered the exported envelope |
| 117 | +**byte for byte, exactly as it was signed**. An implementation must not re-sign it, rebuild it or |
| 118 | +alter a leaf: the point is that a verifier checks the signature over those bytes offline, which is |
| 119 | +what lets a queue wrap, delay and re-deliver an envelope without the far side knowing a queue |
| 120 | +exists. `:ok` acknowledges; any error leaves the entry pending to be offered again, oldest first. |
| 121 | +An implementation that does not export it is one this machine never forwards to, and the queue for |
| 122 | +its scopes simply does not drain. See `lib/trinity/receipts/queue.ex` and |
| 123 | +`test/trinity/receipts/queue_test.exs`. |
| 124 | + |
| 125 | +## `act_hash` is opaque |
| 126 | + |
| 127 | +An authority layer may bind its decision to an act identifier. **Trinity treats it as opaque.** It |
| 128 | +does not parse it, does not recompute it and does not depend on its shape; it carries it and stores |
| 129 | +it beside the decision. |
| 130 | + |
| 131 | +Where an adapter records one, it should record the algorithm beside it, as `act_hash` and |
| 132 | +`act_hash_alg`, so a reader is never guessing which construction produced the digest. The value |
| 133 | +this contract expects is `sha256+rfc8785-v1`: SHA-256 over the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) |
| 134 | +canonical JSON form. Trinity already uses RFC 8785 for its own approval fingerprints and holds a |
| 135 | +conformance vector for it (`test/support/rfc8785_input.json`, `test/support/rfc8785_expected.txt`), |
| 136 | +so the canonicalisation is not a new dependency here. |
| 137 | + |
| 138 | +**`act_hash` is NOT IN TREE.** No field, column or function of that name exists in this repository. |
| 139 | +It is named here so an adapter and this node agree on the word, not because Trinity reads it. |
| 140 | + |
| 141 | +## Packaging an adapter |
| 142 | + |
| 143 | +The adapter lives **outside this repository**, and the release that contains both is built outside |
| 144 | +it too. This tree builds a Trinity that can be pointed at one; it does not build, vendor or depend |
| 145 | +on any. A wrapper release adds the adapter module to the code path and sets `TRINITY_AUTHORITY`. |
| 146 | +Anything more than that is NOT IN TREE. |
| 147 | + |
| 148 | +## Checklist |
| 149 | + |
| 150 | +- [ ] the module is loaded in the release, and `TRINITY_AUTHORITY` names it |
| 151 | +- [ ] `stage/2`, `decide/3`, `execute/3`, `receipt/2` are exported |
| 152 | +- [ ] `stage/2` changes nothing but `staged_at` and `basis` |
| 153 | +- [ ] `decide/3` answers `:allow` or `:deny`, and never falls back to a local decision |
| 154 | +- [ ] `execute/3` runs no tool |
| 155 | +- [ ] `forward_receipt/2` acknowledges bytes it did not alter, or is absent on purpose |
| 156 | +- [ ] under `TRINITY_PROFILE=regulated`, the node boots, which means it did not select `Local` |
0 commit comments