diff --git a/CHANGELOG.md b/CHANGELOG.md index 46512c0ecc..7a40fa4985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. ### Changes +- CLI + - New `access-pass plan` and `access-pass apply`: reconcile access passes against a YAML document describing the intended state — multicast publish/subscribe allowlists and the IBRL (unicast) tenant, keyed by `(client_ip, user_payer)`. `plan` reads and prints the diff, `apply` sends it after a confirmation. The multicast lists are declarative, so a group the document does not name is revoked. `apply --json` emits one object with `changed` first for configuration-management drivers, and requires `--auto-approve` since there is no terminal to confirm on. A converged document is a no-op. Documented in `docs/access-pass-plan-apply.md`. + - A declared subscribe that an EdgeSeat feed already grants is reported as satisfied and costs no transaction; publisher is never feed-covered, so a publish gap on the same group is still reported. + - Declaring `ibrl: ` also pins `last_access_epoch` to unlimited, since the tenant and the epoch are one grant and a finite value fails `connect ibrl` at an unpredictable date. The write reads the pass first and re-sends the type, seat caps and `allow_multiple_ip` unchanged, because `access-pass set` overwrites those from its arguments; it targets the stored pass, so a grant on a shared `0.0.0.0` pass is not written to a different account. + - Two cases are blocked rather than attempted: an access pass that does not exist (granting against an empty PDA would silently mint a `Prepaid` pass with 1/1 seats and no epoch), and a group leaving both allowlists at once (the detach verbs send the role being kept as desired state, so revoking both entries first strands the roles on the User account). + ## [v0.39.0](https://github.com/malbeclabs/doublezero/compare/client/v0.38.0...client/v0.39.0) - 2026-09-04 ### Breaking diff --git a/docs/access-pass-plan-apply.md b/docs/access-pass-plan-apply.md new file mode 100644 index 0000000000..4d300577d1 --- /dev/null +++ b/docs/access-pass-plan-apply.md @@ -0,0 +1,171 @@ +# Declarative access passes: `plan` and `apply` + +Describe the access a fleet should have — multicast publish/subscribe and IBRL (unicast) — in one +YAML document, review what would change, then apply it. + +`doublezero access-pass plan` reports the difference between the document and the ledger, and +writes nothing. `doublezero access-pass apply` takes the same difference and sends it. + +The design and its open questions are in [RFC-28](../rfcs/rfc28-declarative-access-passes.md). + +```console +$ doublezero access-pass plan -f access-passes.yml +$ doublezero access-pass apply -f access-passes.yml +``` + +## The document + +```yaml +defaults: + user_payer: 3UrShLQz2Y9UEaz69QhbZ41px91JYFSWd4hEs33ag3se + +access_passes: + - client_ip: 203.0.113.10 + multicast: + publish: + - mg-marketdata-tob + subscribe: + - mg-marketdata-mbp + + - client_ip: 203.0.113.11 + user_payer: AB3gAfgVBtb3AoJ2GwRGCuzCSWXit4isKLYm3kULWuf7 + ibrl: solana + multicast: + subscribe: + - mg-analytics-mbp +``` + +The pass is keyed by `(client_ip, user_payer)` — its PDA seeds — so those two fields name the +account each entry describes. + +| Field | Scope | Notes | +| --- | --- | --- | +| `defaults.user_payer` | document | Applied to any entry that omits `user_payer`. A fleet shares one payer, so this is usually the only place it appears. | +| `client_ip` | entry, required | With `user_payer`, it is the access pass's PDA seed pair. | +| `user_payer` | entry, optional | Overrides the default. Accepts `me` for the current signer. | +| `ibrl` | entry, optional | Tenant code granting IBRL (unicast) access. One code; omit for none. | +| `multicast.publish` | entry, optional | Group codes this pass may publish to. | +| `multicast.subscribe` | entry, optional | Group codes this pass may subscribe to. | + +**Every field is declarative.** A group the document does not name is revoked from that pass, and +an entry with no `ibrl` has its tenant cleared. An entry with no `multicast` block declares no +groups, and therefore revokes all of them. That is what makes the document a description of +intended state rather than a list of things to add — and it is why `plan` exists, since the +revocations are the half worth seeing before they happen. + +`ibrl` is a scalar rather than a list because a pass admits one tenant, and `access-pass set` is +the only instruction that writes `tenant_allowlist` — setting it is inherently a replace. + +Declaring `ibrl` also pins `last_access_epoch` to unlimited. The epoch gates unicast user creation +only, and any finite value turns a later `connect ibrl` on that IP into a failure at an +unpredictable date; `0` is not "expired" but "no epoch defined", and blocks every unicast type +outright. So the tenant and the epoch are treated as one grant: either half drifting re-sends the +same `access-pass set`. + +Unknown keys are rejected. Every optional field would otherwise default silently, so `subscibe:` +would parse as valid YAML, contribute nothing, and leave the host quietly unsubscribed: + +```console +$ doublezero access-pass plan -f access-passes.yml +Error: invalid access-pass document: access_passes[0].multicast: unknown field `subscibe`, +expected `publish` or `subscribe` at line 6 column 7 +``` + +Unknown group codes are rejected too, and all of them are named at once, because the program +resolves a code to a PDA without checking that anything is there — an unchecked typo writes a +grant pointing at nothing. + +## Reading a plan + +```console +$ doublezero access-pass plan -f access-passes.yml +DoubleZero will perform the following actions: + + # access_pass 203.0.113.10 / 3UrS…g3se (4Fq…2Lm) + + subscriber mg-analytics-mbp + - publisher mg-marketdata-legacy + +IBRL (unicast) access: + + # access_pass 203.0.113.11 / AB3g…Wuf7 (8Bd…9Wp) + + ibrl solana + ~ epoch -> unlimited + +Plan: 1 to add, 1 to remove, 1 IBRL change(s), 3 satisfied, 0 blocked. +``` + +`+` grants, `-` revokes, `~` changes in place. Add `--verbose` to list the grants that are already +in place, and `--json` for a machine-readable object whose `changed` field says whether anything +would move. + +## What `plan` refuses to do + +Blocked items are reported, and both verbs exit non-zero when there are any. + +**A pass that does not exist.** The document does not create access passes. Granting an allowlist +entry against an empty PDA would silently mint a `Prepaid` pass with one unicast and one +multicast seat and no epoch, so a typo'd IP would produce a junk pass that looks real. Create the +pass first with `doublezero access-pass set`. + +**A group leaving both allowlists at once.** The host's detach verbs send the role being *kept* +as desired state, and the program authorizes every `true` against these allowlists. Once both +entries are gone, `multicast unpublish` asks for `subscriber: true` and `multicast unsubscribe` +asks for `publisher: true`, and neither is allowlisted any more — the roles are stranded on the +User account with no legal write to remove them. Detach the host first +(`doublezero multicast unpublish` / `unsubscribe`), then revoke. + +## What it leaves alone + +- **Everything the document does not describe**: the pass type, `allow_multiple_ip`, `DZF_LOCKED`, + the seat caps and EdgeSeat feed seats are preserved. `access-pass set` overwrites those from its + arguments, so an IBRL change reads the pass first and re-sends them unchanged — the write moves + the tenant, pins the epoch, and touches nothing else. +- **The pass a shared grant actually lives on.** A pass with `allow_multiple_ip` is stored at the + `0.0.0.0` PDA and serves any client IP, so a concrete address can resolve to it. Writes target + the stored pass rather than the declared IP, and `plan` warns when the two differ, because every + group granted there is granted to every host using that pass. +- **Subscribe rights granted by a feed.** An EdgeSeat pass's feeds grant subscribe on their + groups in their own metro, so a declared subscribe that a feed already covers is reported as + satisfied and costs no transaction. Publisher is never feed-covered, so a publish gap on the + same group is still a real gap. +- **A group that no longer exists.** An allowlist key with no group behind it cannot be named, so + the document cannot declare it and no revoke is planned for it. + +## Automation + +`apply --json` writes exactly one JSON object to stdout, with `changed` first, so a +configuration-management driver can key off it: + +```yaml +- ansible.builtin.command: + argv: + - doublezero + - access-pass + - apply + - --file=/etc/doublezero/access-passes.yml + - --auto-approve + - --json + register: dz + changed_when: (dz.stdout | from_json).changed +``` + +`--json` requires `--auto-approve` (or `--dry-run`): there is no terminal to answer the +confirmation prompt on, so the CLI refuses rather than hanging on a read that never returns. + +A converged document is a no-op — a second `apply` reports `changed: false` and sends no +transactions. + +## Permissions + +Granting publish requires `MULTICAST_ADMIN`; granting subscribe and changing the IBRL tenant +require `ACCESS_PASS_ADMIN`. A signer holding one but not the other will see that half of the run +succeed and the other half fail, per item, and the command exits non-zero. + +## Known limitation: orphaned allowlist entries + +An allowlist entry whose multicast group has since been deleted cannot be removed, and `plan` +leaves it alone rather than proposing a revoke that would fail. `RemoveMulticastGroup*Allowlist` +requires the group account, and `multicast group delete` closes it — so once the group is gone the +entry is stranded. `multicast group delete` sweeps the allowlists first, but proceeds to delete +even when that sweep reports failures, which is how such an entry arises. Removing them needs a +program-side change. diff --git a/rfcs/rfc28-declarative-access-passes.md b/rfcs/rfc28-declarative-access-passes.md new file mode 100644 index 0000000000..fe2c2b1907 --- /dev/null +++ b/rfcs/rfc28-declarative-access-passes.md @@ -0,0 +1,223 @@ +# RFC-28: Declarative Access Pass Reconciliation + +## Summary + +**Status: `Draft`** + +Two commands, `doublezero access-pass plan` and `doublezero access-pass apply`, reconcile access passes against a YAML document that describes the access a fleet should have. The document names, per pass, the multicast groups it may publish to and subscribe to and the tenant granting it IBRL (unicast) access. `plan` reports the difference between the document and the ledger and writes nothing; `apply` sends that same difference after a confirmation. + +The result is that a fleet's access is described in one reviewable file, the difference between intent and reality is visible before anything is written, and a converged document is a no-op that configuration management can run on every pass. + +## Motivation + +Access is currently granted one instruction at a time. Adding a group to a host is: + +``` +doublezero multicast group allowlist subscriber add --code --user-payer --client-ip +``` + +That is one invocation per (group x pass x role). Six servers across four groups in both roles is forty-eight commands, and the IBRL half is a separate `access-pass set` on top. Three problems follow. + +**There is no way to see the whole picture.** Answering "which groups does 203.0.113.10 have?" means running `multicast group allowlist ... list --code X` once per group and reading the output by eye, because the per-group listing is the only view and it scans every access pass each time. The question an operator actually asks is a grid — hosts against groups — and no command produces one. + +**There is no way to see what a change would do.** Every verb writes immediately. An operator who wants to know whether a host is already correct has to infer it from listings, and an operator who wants to remove access has no way to preview the removals. + +**The state is not described anywhere.** The intended access lives in whoever last ran the commands. Two hosts that should be identical drift, and nothing detects it. A configuration-management driver can be written on top of the individual verbs — one exists — but it has to compute the diff itself in the templating language, which is where it is hardest to test. + +The failure this produces is quiet rather than loud: a grant that was never issued looks exactly like one that was, until the traffic does not arrive. + +## New Terminology + +- **Definition document**: the YAML file describing the access passes and the access each should have. The desired state. +- **Plan**: the set of writes that would bring the ledger to the state the document describes, together with the declared grants already satisfied and the entries the tool refuses to act on. +- **Declarative field**: a field whose absence is meaningful. A group the document does not name is revoked; an entry with no `ibrl` has its tenant cleared. +- **Satisfied grant**: a grant the document declares that the ledger already provides, whether through the allowlist or through a feed. +- **Blocked entry**: a declared pass the plan refuses to act on, with the reason. +- **IBRL grant**: unicast access, comprising the pass's tenant and its `last_access_epoch`, which are written together. + +## Alternatives Considered + +1. **Do nothing.** Keep the per-instruction verbs. Rejected: it leaves the three problems above, and it pushes the diff into whatever driver sits on top, where it is untested. + +2. **Bulk flags on the existing verbs** — repeated `--code` / `--client-ip` / `--user-payer` forming a cross product. This shortens the forty-eight commands to two and is worth doing on its own merits, but it grants only. It cannot revoke what is no longer wanted, cannot report drift, and still describes nothing. Complementary rather than an alternative. + +3. **A generic reconciler across every resource type** — `doublezero plan` / `apply` over a document covering devices, links, locations, tenants and passes, with a provider per type. Rejected for now as disproportionate: it needs a resource/action boundary, a dependency ordering between types, and a much larger schema to agree on, for one resource's worth of demonstrated need. The design here does not preclude it; `access-pass plan` can be promoted to `plan --target access_pass` if a second resource earns it. + +4. **Solve it entirely in configuration management** — a native Ansible module, with the desired state expressed as playbook tasks and no file format at all. This is attractive when Ansible is the only driver: it gets check mode, diff and inventory looping for free. Rejected as the primary form because it makes the capability unavailable to anyone not running Ansible, leaves no single artifact describing intended state, and still needs the CLI to expose the diff — so it moves the work rather than removing it. A module wrapping these commands remains a reasonable addition later. + +5. **A Terraform provider.** Rejected: it introduces state files and a second source of truth for accounts that are already authoritative onchain, and the plan/apply ergonomics can be had without either. + +6. **TOML rather than YAML.** Rejected: `serde_yaml` is already a workspace dependency and already used by `export`, configuration management is YAML-native, and adding `toml` buys nothing. + +## Detailed Design + +### The document + +```yaml +defaults: + user_payer: 3UrShLQz2Y9UEaz69QhbZ41px91JYFSWd4hEs33ag3se + +access_passes: + - client_ip: 203.0.113.10 + multicast: + publish: + - mg-marketdata-tob + - mg-marketdata-mbp + + - client_ip: 203.0.113.12 + user_payer: AB3gAfgVBtb3AoJ2GwRGCuzCSWXit4isKLYm3kULWuf7 + ibrl: solana + multicast: + subscribe: + - mg-analytics-mbp + + - client_ip: 203.0.113.13 + ibrl: solana +``` + +| Field | Scope | Meaning | +| --- | --- | --- | +| `defaults.user_payer` | document | Applied to any entry omitting `user_payer`. | +| `client_ip` | entry, required | With `user_payer`, the pass's PDA seeds. | +| `user_payer` | entry, optional | Overrides the default. Accepts `me`. | +| `ibrl` | entry, optional | Tenant code granting unicast access. One code. | +| `multicast.publish` | entry, optional | Group codes this pass may publish to. | +| `multicast.subscribe` | entry, optional | Group codes this pass may subscribe to. | + +The schema deliberately matches the per-host declaration an operator already writes in configuration-management inventory, so both describe a host the same way. + +**Every field is declarative.** A group the document does not name is revoked from that pass; an entry with no `ibrl` has its tenant cleared; an entry with no `multicast` block declares no groups and therefore revokes all of them. This is what makes the document a description of state rather than a list of additions, and it is the reason `plan` exists — the revocations are the half worth reviewing. + +`ibrl` is a scalar because a pass admits one tenant and `SetAccessPass` is the only instruction that writes `tenant_allowlist`; setting it is inherently a replace, so a list would mislead. + +**Unknown keys are rejected.** Every optional field is read with a default, so `subscibe:` would otherwise parse as valid YAML, contribute nothing, and leave the host quietly unsubscribed. `deny_unknown_fields` on every type makes a misspelling visible: + +```console +Error: invalid access-pass document: access_passes[2]: unknown field `ibrll`, +expected one of `client_ip`, `user_payer`, `ibrl`, `multicast` at line 27 column 5 +``` + +Unknown group and tenant codes are rejected the same way, and all of them are named at once, because the program resolves a code to a PDA without checking that anything is behind it — an unchecked typo writes a grant pointing at nothing. + +The payer is resolved after parsing rather than during it, so a document can be validated with no keypair and no network. + +### Diffing + +One document entry produces up to two kinds of change. + +**Multicast.** For each role, the declared set is compared against the pass's `mgroup_pub_allowlist` / `mgroup_sub_allowlist`. Missing entries become grants, undeclared entries become revokes. + +A declared subscribe that a feed already grants is reported as satisfied and never written. An EdgeSeat pass's feeds grant subscribe on their groups in their own metro, so re-granting spends a transaction, changes nothing, and would make every run report as changed — which is what breaks idempotency for a driver. Publisher is never feed-covered, so a publish gap on the same group is still a real gap. + +An allowlist key that resolves to no group is skipped rather than revoked. It cannot be named, so the document cannot declare it, and a plan line printing a bare pubkey would be unreadable. See *Known limitation* below. + +**IBRL.** The declared tenant is compared against the pass's first `tenant_allowlist` entry, and the tenant and `last_access_epoch` are treated as one grant. A declared `ibrl` requires the epoch to be unlimited: the epoch gates unicast user creation only, any finite value turns a later `connect ibrl` on that IP into a failure at an unpredictable date, and `0` is not "expired" but "no epoch defined", which blocks every unicast type outright. Either half drifting re-sends the same `SetAccessPass`. + +### Reads + +The multicast groups are one scan for the whole document. Tenants are read when the document declares an `ibrl`, and otherwise only once a pass turns out to carry one — a pass with a tenant still has to be cleared, so the scan cannot be skipped merely because the document is silent. Feeds are read only if some pass carries a feed seat. Access passes are fetched per entry, because each is a distinct PDA. + +### Writing + +Multicast changes use the four existing allowlist instructions, one transaction each; there is no multi-group allowlist instruction. + +The IBRL change uses `SetAccessPass`, which overwrites `accesspass_type`, `last_access_epoch`, `allow_multiple_ip` and both seat caps from its arguments. So the write reads the pass first and re-sends those unchanged: the transaction moves the tenant, pins the epoch, and touches nothing else. (`mgroup_*_allowlist` and `DZF_LOCKED` survive a `set` untouched, and the program preserves EdgeSeat feed seats when both the stored and incoming types are EdgeSeat.) + +**Writes target the stored pass, not the declared IP.** A pass with `allow_multiple_ip` is stored at the PDA seeded with `0.0.0.0` and serves any client IP, and access-pass resolution prefers it over an exact-IP pass. `SetAccessPass` seeds its PDA from the `client_ip` argument, so sending the declared address would write a different account than the one the plan described. The plan warns whenever a declared address resolves to a shared pass, because every group granted there is granted to every host using it. + +### Output + +```console +$ doublezero access-pass plan -f access-passes.yml +DoubleZero will perform the following actions: + + # access_pass 203.0.113.10 / 3UrS…g3se (4Fq…2Lm) + + subscriber mg-analytics-mbp + - publisher mg-marketdata-legacy + +IBRL (unicast) access: + + # access_pass 203.0.113.12 / AB3g…Wuf7 (8Bd…9Wp) + + ibrl solana + ~ epoch -> unlimited + +Plan: 1 to add, 1 to remove, 1 IBRL change(s), 3 satisfied, 0 blocked. +``` + +`+` grants, `-` revokes, `~` changes in place. `--verbose` lists the satisfied grants; `--json` emits the same plan as a machine-readable object. + +`apply` prints the same plan, prompts, sends each change, and reports the outcome per item. Each change is its own transaction, so a run continues past a failure and names what did and did not land, then exits non-zero. + +### Blocked entries + +Two situations are reported and refused rather than attempted. Both verbs exit non-zero when there are any. + +**A pass that does not exist.** The document does not create access passes. `AddMulticastGroup*Allowlist` against an empty PDA silently creates a `Prepaid` pass with one unicast seat, one multicast seat and `last_access_epoch: 0`, so a typo'd address would mint a junk pass that looks real. The operator creates the pass with `access-pass set` first. + +**A group leaving both allowlists at once.** The host's detach verbs send the role being *kept* as desired state, and the program authorizes every `true` against these allowlists. Once both entries are gone, `multicast unpublish` asks for `subscriber: true` and `multicast unsubscribe` asks for `publisher: true`, and neither is allowlisted any more — the roles are stranded on the User account with no legal write to remove them. Detaching the host first, then revoking, is the safe order. + +### Automation + +`apply --json` writes exactly one JSON object to stdout, with `changed` first: + +```yaml +- ansible.builtin.command: + argv: [doublezero, access-pass, apply, --file=/etc/doublezero/access-passes.yml, + --auto-approve, --json] + register: dz + changed_when: (dz.stdout | from_json).changed +``` + +`--json` requires `--auto-approve` (or `--dry-run`), because there is no terminal to answer the confirmation prompt on and the alternative is hanging on a read that never returns. + +A converged document is a no-op: the second `apply` reports `changed: false` and sends nothing. That property depends on the feed-coverage rule above. + +## Impact + +**Codebase.** Three new modules under `smartcontract/cli/src/accesspass/` — the document, the plan engine and renderer, and the apply verb — plus two subcommand arms. No new dependency: `serde_yaml` is already in the crate. No program change. + +**Operations.** A fleet's access becomes a file that can be reviewed, diffed and version-controlled. The intended use is that the file is templated from configuration-management inventory, where the addresses already live, so the document is generated rather than hand-maintained. + +**Performance.** A run costs one multicast-group scan, at most one tenant scan, at most one feed scan, and one account fetch per declared pass — against the current cost of one full access-pass scan per group listed. Writes are unchanged in number and kind, minus the ones the feed-coverage rule now skips. + +**Existing commands.** Untouched. The per-instruction verbs remain, and are still the right tool for a one-off. + +## Security Considerations + +The commands write only through existing instructions and add no privilege. Authorization is enforced onchain in every case; the plan is a client-side view and is never a substitute for the program's checks. + +Granting publish requires `MULTICAST_ADMIN`; granting subscribe and changing the tenant require `ACCESS_PASS_ADMIN`. A signer holding one but not the other sees that half of a run succeed and the other half fail, per item. That is reported rather than hidden, so a partially applied run is legible. + +The declarative semantics are the main new hazard: a document that omits a group revokes it, and a document that omits `ibrl` clears a tenant, which can remove a live host's unicast access. Three things bound it — `plan` writes nothing and shows the revocations, `apply` prints the same plan and prompts before sending, and `--auto-approve` has to be passed deliberately. An operator adopting an existing fleet should write the document to describe reality and confirm the first `plan` is empty before changing anything. + +The read-modify-write on `SetAccessPass` carries a small race: another writer can change a preserved field between the read and the write. The window is one round trip, and the alternative — omitting the fields — resets them unconditionally. Optional fields on `SetAccessPassArgs` would remove the race; see Open Questions. + +Refusing to create a missing access pass is a deliberate safety property, not a limitation: it keeps a mistyped address from minting a pass rather than erroring. + +## Backward Compatibility + +Additive. Two new subcommands; no existing command, flag, output or instruction changes. The document format is new, so nothing depends on it yet — which is the moment to settle the open questions below. + +## Open Questions + +1. **Is `defaults` worth keeping?** It saves repeating one pubkey per entry, but `user_payer` together with `client_ip` *is* the account address, and a wrong default silently reconciles a different account for every entry that omits it. Configuration management already handles the fleet-wide case by interpolating the value per host rather than defaulting it. Removing the block and requiring `user_payer` per entry is the safer default. + +2. **Should an omitted `ibrl` clear the tenant, or leave it alone?** Clearing is consistent with the multicast lists and with a document that claims to describe intended state. Leaving it alone treats an absent key as "not managed here", which is what the existing configuration-management role does, and is less destructive when a document is written by someone unaware of a pass's unicast grant. The two readings cannot both be right, and this decides how safe a partially-specified document is. + +3. **Where should the verbs live?** `access-pass plan` is honest about the scope and does not claim generality. `doublezero plan` reads better and leaves room for other resources, but promises something not yet delivered. Renaming later is cheap while nothing depends on it. + +4. **Should `plan` offer `--detailed-exitcode`?** Following `terraform plan` — 0 no changes, 1 error, 2 changes pending — would serve cron and CI without parsing JSON. It needs exit-code plumbing through the binary that no other verb has, and `--json`'s `changed` field already covers the driver case. + +5. **Should `apply` be able to create missing access passes** behind an explicit flag, rather than blocking? It would make a document self-sufficient, at the cost of the safety property in Security Considerations, and it needs the pass type and caps to be declarable — which widens the schema considerably. + +6. **Optional fields on `SetAccessPassArgs`.** Making the arguments `Option` and having the processor write only `Some` values would let the IBRL write stop reading first, removing the race. It generalizes what the processor already does for the `ALLOW_MULTIPLE_IP` bit and for EdgeSeat feed seats, but it is a program change and is out of scope here. + +## Known limitation: orphaned allowlist entries + +An allowlist entry whose multicast group has since been deleted cannot be removed by anyone. `RemoveMulticastGroup*Allowlist` validates that the group account is owned by the program and deserializes it, and `DeleteMulticastGroup` closes that account — resizing it to zero and reassigning it to the system program. Once the group is gone, no call can name it. The SDK command fails earlier still, when it tries to resolve the code. + +`plan` therefore skips such entries rather than proposing a revoke that would always fail, and reports nothing for them. + +These entries arise from `multicast group delete`, which sweeps every access pass's allowlists before deleting the group but proceeds to delete even when that sweep reports failures — the failures surface as a warning after the group is already closed. The permission asymmetry makes it reachable in practice: a signer holding `ACCESS_PASS_ADMIN` but not `MULTICAST_ADMIN` fails every publisher removal in the sweep and then deletes the group anyway. + +Two fixes are worth considering separately from this RFC. Aborting the delete when the sweep has failures would stop new orphans being created, and is a small change. Removing the ones that already exist needs a program-side instruction that drops a pubkey from a pass's allowlists without requiring the group account. diff --git a/smartcontract/cli/src/accesspass/apply.rs b/smartcontract/cli/src/accesspass/apply.rs new file mode 100644 index 0000000000..161bf468d3 --- /dev/null +++ b/smartcontract/cli/src/accesspass/apply.rs @@ -0,0 +1,814 @@ +//! Converges the ledger onto an access-pass definition document. +//! +//! Builds the same plan `plan` prints, shows it, asks for confirmation, then sends each change +//! and reports the outcome per item. Each allowlist change is its own instruction and its own +//! transaction — there is no multi-group allowlist instruction — so a run continues past a +//! failure and names what did and did not land. + +use crate::{ + accesspass::{ + desired::AccessPassDocument, + plan::{build_plan, render_plan, AccessPassPlan, IbrlChange, Op, PlannedChange, Role}, + }, + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_cli_core::CliContext; +use doublezero_sdk::commands::{ + accesspass::{get::GetAccessPassCommand, set::SetAccessPassCommand}, + multicastgroup::allowlist::{ + publisher::{ + add::AddMulticastGroupPubAllowlistCommand, + remove::RemoveMulticastGroupPubAllowlistCommand, + }, + subscriber::{ + add::AddMulticastGroupSubAllowlistCommand, + remove::RemoveMulticastGroupSubAllowlistCommand, + }, + }, +}; +use serde::Serialize; +use std::{ + io::{BufRead, Write}, + path::PathBuf, +}; + +/// Reads an access-pass definition document and converges the ledger onto it. +#[derive(Args, Debug)] +pub struct ApplyAccessPassCliCommand { + /// Path to the access-pass definition document (YAML) + #[arg(long, short = 'f')] + pub file: PathBuf, + /// Show the plan and exit without writing anything + #[arg(long, default_value_t = false)] + pub dry_run: bool, + /// Skip the confirmation prompt + #[arg(long, alias = "force", default_value_t = false)] + pub auto_approve: bool, + /// Also list the grants that are already satisfied + #[arg(long, default_value_t = false)] + pub verbose: bool, + /// Output as pretty JSON + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Debug, Serialize)] +struct ApplyResult { + #[serde(flatten)] + change: PlannedChange, + /// `applied` or `failed`. + state: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct IbrlResult { + #[serde(flatten)] + change: IbrlChange, + /// `applied` or `failed`. + state: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct ApplyJson<'a> { + changed: bool, + counts: Counts, + results: &'a [ApplyResult], + ibrl_results: &'a [IbrlResult], + plan: &'a AccessPassPlan, +} + +#[derive(Debug, Serialize)] +struct Counts { + applied: usize, + failed: usize, + satisfied: usize, + blocked: usize, +} + +impl ApplyAccessPassCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + input: &mut R, + ) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + // A JSON consumer has no terminal to answer the prompt on, so make the caller say up + // front that no one is watching rather than hanging on a read that never returns. + if self.json && !self.dry_run && !self.auto_approve { + eyre::bail!( + "--json requires --auto-approve (or --dry-run); there is no way to confirm" + ); + } + + let document = AccessPassDocument::from_path(&self.file)?; + let desired = document.resolve(client.get_payer())?; + let plan = build_plan(client, &desired)?; + + if !self.json { + render_plan(out, &plan, self.verbose)?; + } + + if self.dry_run { + if self.json { + emit_json(out, &plan, &[], &[])?; + } else { + writeln!(out, "\n[dry-run] nothing was sent.")?; + } + return blocked_result(&plan); + } + + if plan.is_empty() { + if self.json { + emit_json(out, &plan, &[], &[])?; + } + return blocked_result(&plan); + } + + if !self.auto_approve { + write!(out, "\nDo you want to perform these actions? [y/N]: ")?; + out.flush()?; + let mut answer = String::new(); + input.read_line(&mut answer)?; + if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") { + writeln!(out, "Aborted.")?; + return Ok(()); + } + writeln!(out)?; + } + + let mut results = Vec::with_capacity(plan.changes.len()); + let mut ibrl_results: Vec = Vec::with_capacity(plan.ibrl_changes.len()); + for change in &plan.changes { + let outcome = send(client, change); + match outcome { + Ok(signature) => { + if !self.json { + let sign = if change.op == Op::Grant { '+' } else { '-' }; + writeln!( + out, + " {sign} {} {} {} {signature} ✓", + change.role.label(), + change.group, + change.client_ip + )?; + } + results.push(ApplyResult { + change: change.clone(), + state: "applied", + signature: Some(signature), + error: None, + }); + } + Err(err) => { + if !self.json { + let sign = if change.op == Op::Grant { '+' } else { '-' }; + writeln!( + out, + " {sign} {} {} {} {err} ✗", + change.role.label(), + change.group, + change.client_ip + )?; + } + results.push(ApplyResult { + change: change.clone(), + state: "failed", + signature: None, + error: Some(err.to_string()), + }); + } + } + } + + for change in &plan.ibrl_changes { + let label = match (&change.from, &change.to) { + (None, Some(to)) => format!("+ ibrl {to}"), + (Some(from), None) => format!("- ibrl {from}"), + (Some(from), Some(to)) => format!("~ ibrl {from} -> {to}"), + (None, None) => "~ ibrl (epoch only)".to_string(), + }; + match send_ibrl(client, change) { + Ok(signature) => { + if !self.json { + writeln!(out, " {label} {} {signature} ✓", change.client_ip)?; + } + ibrl_results.push(IbrlResult { + change: change.clone(), + state: "applied", + signature: Some(signature), + error: None, + }); + } + Err(err) => { + if !self.json { + writeln!(out, " {label} {} {err} ✗", change.client_ip)?; + } + ibrl_results.push(IbrlResult { + change: change.clone(), + state: "failed", + signature: None, + error: Some(err.to_string()), + }); + } + } + } + + let applied = results.iter().filter(|r| r.state == "applied").count() + + ibrl_results.iter().filter(|r| r.state == "applied").count(); + let failed = results.len() + ibrl_results.len() - applied; + + if self.json { + emit_json(out, &plan, &results, &ibrl_results)?; + } else { + writeln!(out, "\nApply complete! {applied} applied, {failed} failed.")?; + } + + if failed > 0 { + eyre::bail!("{failed} of {} allowlist changes failed", results.len()); + } + blocked_result(&plan) + } +} + +/// The blocked items are the part of the document this run deliberately did not do, so the exit +/// code has to say so even when everything else succeeded. +fn blocked_result(plan: &AccessPassPlan) -> eyre::Result<()> { + if plan.blocked.is_empty() { + return Ok(()); + } + eyre::bail!( + "{} declared access pass(es) could not be reconciled; see the blocked items above", + plan.blocked.len() + ) +} + +fn send(client: &C, change: &PlannedChange) -> eyre::Result { + let code = change.group.clone(); + let client_ip = change.client_ip; + let user_payer = change.user_payer; + + let signature = match (change.role, change.op) { + (Role::Publisher, Op::Grant) => { + client.add_multicastgroup_pub_allowlist(AddMulticastGroupPubAllowlistCommand { + pubkey_or_code: code, + client_ip, + user_payer, + })? + } + (Role::Publisher, Op::Revoke) => { + client.remove_multicastgroup_pub_allowlist(RemoveMulticastGroupPubAllowlistCommand { + pubkey_or_code: code, + client_ip, + user_payer, + })? + } + (Role::Subscriber, Op::Grant) => { + client.add_multicastgroup_sub_allowlist(AddMulticastGroupSubAllowlistCommand { + pubkey_or_code: code, + client_ip, + user_payer, + })? + } + (Role::Subscriber, Op::Revoke) => { + client.remove_multicastgroup_sub_allowlist(RemoveMulticastGroupSubAllowlistCommand { + pubkey_or_code: code, + client_ip, + user_payer, + })? + } + // IBRL is not an allowlist entry; it is written by `access-pass set` through + // `send_ibrl`, and the planner never puts it in `changes`. + (Role::Ibrl, _) => { + eyre::bail!("internal error: an IBRL change reached the allowlist writer") + } + }; + + Ok(signature.to_string()) +} + +/// Re-sends `access-pass set` with the tenant the document declares. +/// +/// `set` overwrites `accesspass_type`, `last_access_epoch`, the `ALLOW_MULTIPLE_IP` flag and both +/// seat caps from its arguments, so the pass is read back and those are sent unchanged — the write +/// moves the tenant, pins the epoch, and touches nothing else. (`mgroup_*_allowlist` and +/// `DZF_LOCKED` survive a `set` untouched; EdgeSeat feed seats are preserved by the program when +/// both the stored and incoming types are EdgeSeat.) +/// +/// The read is deliberately fresh rather than carried from the plan: the seat caps are live +/// counters that the plan may already have seen go stale. +fn send_ibrl(client: &C, change: &IbrlChange) -> eyre::Result { + let (_, pass) = client + .get_accesspass(GetAccessPassCommand { + client_ip: change.client_ip, + user_payer: change.user_payer, + })? + .ok_or_else(|| { + eyre::eyre!( + "access pass for {} / {} disappeared between plan and apply", + change.client_ip, + change.user_payer + ) + })?; + + // Address the pass that actually holds the grant. A shared pass is stored at 0.0.0.0, and + // `set` seeds the PDA from this value — sending the concrete IP would write a different + // account than the one the plan described. + let signature = client.set_accesspass(SetAccessPassCommand { + accesspass_type: pass.accesspass_type.clone(), + client_ip: pass.client_ip, + user_payer: pass.user_payer, + last_access_epoch: if change.to.is_some() { + u64::MAX + } else { + pass.last_access_epoch + }, + allow_multiple_ip: pass.allow_multiple_ip(), + tenant: change.to_pk, + max_unicast_users: pass.max_unicast_users, + max_multicast_users: pass.max_multicast_users, + })?; + + Ok(signature.to_string()) +} + +fn emit_json( + out: &mut W, + plan: &AccessPassPlan, + results: &[ApplyResult], + ibrl_results: &[IbrlResult], +) -> eyre::Result<()> { + let applied = results.iter().filter(|r| r.state == "applied").count() + + ibrl_results.iter().filter(|r| r.state == "applied").count(); + let total = results.len() + ibrl_results.len(); + let json = serde_json::to_string_pretty(&ApplyJson { + changed: applied > 0, + counts: Counts { + applied, + failed: total - applied, + satisfied: plan.satisfied.len(), + blocked: plan.blocked.len(), + }, + results, + ibrl_results, + plan, + })?; + writeln!(out, "{json}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ApplyAccessPassCliCommand; + use crate::{ + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, + tests::utils::create_test_client, + }; + use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; + use doublezero_sdk::{AccountType, MulticastGroup, MulticastGroupStatus}; + use doublezero_serviceability::state::accesspass::{ + AccessPass, AccessPassStatus, AccessPassType, + }; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::{collections::HashMap, io::Cursor, net::Ipv4Addr}; + + const IP: Ipv4Addr = Ipv4Addr::new(203, 0, 113, 10); + + fn signature() -> Signature { + Signature::from([ + 120, 138, 162, 185, 59, 209, 241, 157, 71, 157, 74, 131, 4, 87, 54, 28, 38, 180, 222, + 82, 64, 62, 61, 62, 22, 46, 17, 203, 187, 136, 62, 43, 11, 38, 235, 17, 239, 82, 240, + 139, 130, 217, 227, 214, 9, 242, 141, 223, 94, 29, 184, 110, 62, 32, 87, 137, 63, 139, + 100, 221, 20, 137, 4, 5, + ]) + } + + /// A client holding one group `g1` and a pass whose subscriber allowlist is `sub_allow`. + fn fixture( + sub_allow_has_g1: bool, + ) -> ( + crate::doublezerocommand::MockCliCommand, + Pubkey, + tempfile::NamedTempFile, + ) { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let group_pk = Pubkey::new_unique(); + + client.expect_get_payer().returning(move || payer); + client + .expect_check_requirements() + .with(mockall::predicate::eq(CHECK_ID_JSON | CHECK_BALANCE)) + .returning(|_| Ok(())); + client.expect_list_multicastgroup().returning(move |_| { + Ok(HashMap::from([( + group_pk, + MulticastGroup { + account_type: AccountType::MulticastGroup, + index: 1, + bump_seed: 1, + owner: Pubkey::new_unique(), + tenant_pk: Pubkey::default(), + multicast_ip: [239, 0, 0, 1].into(), + max_bandwidth: 1_000_000_000, + status: MulticastGroupStatus::Activated, + code: "g1".to_string(), + publisher_count: 0, + subscriber_count: 0, + }, + )])) + }); + + let sub_allow = if sub_allow_has_g1 { + vec![group_pk] + } else { + vec![] + }; + let pass = AccessPass { + account_type: AccountType::AccessPass, + bump_seed: 255, + accesspass_type: AccessPassType::Prepaid, + client_ip: IP, + user_payer: payer, + last_access_epoch: u64::MAX, + connection_count: 0, + status: AccessPassStatus::Connected, + mgroup_pub_allowlist: vec![], + mgroup_sub_allowlist: sub_allow, + tenant_allowlist: vec![], + owner: Pubkey::new_unique(), + flags: 0, + unicast_user_count: 0, + max_unicast_users: 1, + multicast_user_count: 0, + max_multicast_users: 1, + }; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), pass.clone())))); + + let doc = format!( + "defaults:\n user_payer: {payer}\naccess_passes:\n - client_ip: {IP}\n multicast:\n subscribe: [g1]\n" + ); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, doc.as_bytes()).unwrap(); + (client, payer, file) + } + + #[test] + fn applies_the_missing_grant_after_confirmation() { + let (mut client, _payer, file) = fixture(false); + client + .expect_add_multicastgroup_sub_allowlist() + .times(1) + .returning(move |_| Ok(signature())); + + let mut out = Vec::new(); + let mut input = Cursor::new(b"y\n".to_vec()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: false, + verbose: false, + json: false, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("+ subscriber g1"), "{text}"); + assert!( + text.contains("Do you want to perform these actions?"), + "{text}" + ); + assert!( + text.contains("Apply complete! 1 applied, 0 failed."), + "{text}" + ); + } + + #[test] + fn answering_no_sends_nothing() { + let (mut client, _payer, file) = fixture(false); + // No expectation set for the write: mockall fails the test if it is called at all. + client.expect_add_multicastgroup_sub_allowlist().never(); + + let mut out = Vec::new(); + let mut input = Cursor::new(b"n\n".to_vec()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: false, + verbose: false, + json: false, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + assert!(String::from_utf8(out).unwrap().contains("Aborted.")); + } + + #[test] + fn dry_run_shows_the_plan_and_sends_nothing() { + let (mut client, _payer, file) = fixture(false); + client.expect_add_multicastgroup_sub_allowlist().never(); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: true, + auto_approve: false, + verbose: false, + json: false, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("+ subscriber g1"), "{text}"); + assert!(text.contains("[dry-run] nothing was sent."), "{text}"); + } + + #[test] + fn a_second_run_is_a_no_op_and_reports_unchanged() { + // The pass already grants g1, which is what a re-run of a converged document looks like. + let (mut client, _payer, file) = fixture(true); + client.expect_add_multicastgroup_sub_allowlist().never(); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: true, + verbose: false, + json: true, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + let json: serde_json::Value = + serde_json::from_slice(&out).expect("stdout must be exactly one JSON object"); + assert_eq!(json["changed"], false); + assert_eq!(json["counts"]["applied"], 0); + assert_eq!(json["counts"]["satisfied"], 1); + } + + #[test] + fn json_emits_one_object_with_changed_true_after_a_write() { + let (mut client, _payer, file) = fixture(false); + client + .expect_add_multicastgroup_sub_allowlist() + .times(1) + .returning(move |_| Ok(signature())); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: true, + verbose: false, + json: true, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + let json: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(json["changed"], true); + assert_eq!(json["counts"]["applied"], 1); + assert_eq!(json["results"][0]["state"], "applied"); + assert_eq!(json["results"][0]["group"], "g1"); + assert_eq!(json["results"][0]["op"], "grant"); + } + + #[test] + fn json_without_auto_approve_is_refused_rather_than_hanging() { + let (client, _payer, file) = fixture(false); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let err = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: false, + verbose: false, + json: true, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ) + .unwrap_err(); + + assert!(err.to_string().contains("--auto-approve"), "{err}"); + } + + /// The IBRL write must address the pass that actually holds the grant and re-send every field + /// `access-pass set` would otherwise clobber. + #[test] + fn setting_the_tenant_targets_the_stored_pass_and_preserves_the_rest() { + use doublezero_sdk::commands::accesspass::set::SetAccessPassCommand; + use doublezero_serviceability::state::{ + accesspass::{FeedSeat, ALLOW_MULTIPLE_IP}, + tenant::{Tenant, TenantBillingConfig, TenantPaymentStatus}, + }; + + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let tenant_pk = Pubkey::new_unique(); + let feed_key = Pubkey::new_unique(); + + client.expect_get_payer().returning(move || payer); + client.expect_check_requirements().returning(|_| Ok(())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(HashMap::new())); + // The pass carries a feed seat, so the planner scans feeds to work out what they grant. + client.expect_list_feed().returning(|_| Ok(HashMap::new())); + client.expect_list_tenant().returning(move |_| { + Ok(HashMap::from([( + tenant_pk, + Tenant { + account_type: AccountType::Tenant, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "solana".to_string(), + vrf_id: 100, + reference_count: 1, + administrators: vec![], + token_account: Pubkey::default(), + payment_status: TenantPaymentStatus::Paid, + metro_routing: false, + route_liveness: false, + billing: TenantBillingConfig::default(), + include_topologies: vec![], + }, + )])) + }); + + // A shared EdgeSeat pass: stored at 0.0.0.0, allow_multiple_ip set, non-default caps, a + // finite epoch, and a feed seat. Every one of those is a field `set` would reset. + let seats = vec![FeedSeat { + feed_key, + max_users: 2, + max_future_users: 2, + current_users: 1, + anniversary_day: 1, + window_end: 0, + terminates_at: 0, + }]; + let stored = AccessPass { + account_type: AccountType::AccessPass, + bump_seed: 255, + accesspass_type: AccessPassType::EdgeSeat(seats.clone()), + client_ip: Ipv4Addr::UNSPECIFIED, + user_payer: payer, + last_access_epoch: 200, + connection_count: 0, + status: AccessPassStatus::Connected, + mgroup_pub_allowlist: vec![], + mgroup_sub_allowlist: vec![], + tenant_allowlist: vec![], + owner: Pubkey::new_unique(), + flags: ALLOW_MULTIPLE_IP, + unicast_user_count: 1, + max_unicast_users: 3, + multicast_user_count: 2, + max_multicast_users: 5, + }; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), stored.clone())))); + + client + .expect_set_accesspass() + .withf(move |cmd: &SetAccessPassCommand| { + cmd.client_ip == Ipv4Addr::UNSPECIFIED // the stored pass, not 203.0.113.10 + && cmd.tenant == tenant_pk + && cmd.last_access_epoch == u64::MAX // a declared ibrl pins the epoch + && cmd.allow_multiple_ip + && cmd.max_unicast_users == 3 + && cmd.max_multicast_users == 5 + && matches!(cmd.accesspass_type, AccessPassType::EdgeSeat(_)) + }) + .times(1) + .returning(move |_| Ok(signature())); + + let doc = format!( + "access_passes:\n - client_ip: {IP}\n user_payer: {payer}\n ibrl: solana\n" + ); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, doc.as_bytes()).unwrap(); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let res = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: true, + verbose: false, + json: false, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ); + + assert!(res.is_ok(), "{res:?}"); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("+ ibrl solana"), "{text}"); + assert!( + text.contains("Apply complete! 1 applied, 0 failed."), + "{text}" + ); + } + + #[test] + fn a_failed_write_is_reported_and_exits_non_zero() { + let (mut client, _payer, file) = fixture(false); + client + .expect_add_multicastgroup_sub_allowlist() + .returning(|_| Err(eyre::eyre!("NotAllowed"))); + + let mut out = Vec::new(); + let mut input = Cursor::new(Vec::new()); + let err = block_on( + ApplyAccessPassCliCommand { + file: file.path().to_path_buf(), + dry_run: false, + auto_approve: true, + verbose: false, + json: false, + } + .execute( + &cli_context_default_for_tests(), + &client, + &mut out, + &mut input, + ), + ) + .unwrap_err(); + + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("NotAllowed"), "{text}"); + assert!(text.contains("0 applied, 1 failed"), "{text}"); + assert!(err.to_string().contains("1 of 1"), "{err}"); + } +} diff --git a/smartcontract/cli/src/accesspass/desired.rs b/smartcontract/cli/src/accesspass/desired.rs new file mode 100644 index 0000000000..76f62bf349 --- /dev/null +++ b/smartcontract/cli/src/accesspass/desired.rs @@ -0,0 +1,347 @@ +//! The access-pass definition document — the desired state `plan` and `apply` reconcile against. +//! +//! One entry per access pass, keyed by `(client_ip, user_payer)` — the pass's PDA seeds — and +//! naming the multicast groups it may publish to and subscribe to, plus its IBRL (unicast) tenant. +//! The schema matches the declaration an operator already writes in configuration management, so +//! both describe a host the same way: +//! +//! ```yaml +//! defaults: +//! user_payer: 3UrShLQz2Y9UEaz69QhbZ41px91JYFSWd4hEs33ag3se +//! +//! access_passes: +//! - client_ip: 203.0.113.10 +//! multicast: +//! publish: +//! - mg-marketdata-tob +//! subscribe: +//! - mg-marketdata-mbp +//! +//! - client_ip: 203.0.113.11 +//! user_payer: AB3gAfgVBtb3AoJ2GwRGCuzCSWXit4isKLYm3kULWuf7 +//! ibrl: solana +//! multicast: +//! subscribe: +//! - mg-analytics-mbp +//! ``` +//! +//! **Every field is declarative**: a group the document does not name is revoked from that pass, +//! and an entry with no `ibrl` has its tenant cleared. An entry with no `multicast` block declares +//! no groups, and so revokes all of them. +//! +//! `ibrl` is a scalar because a pass admits one tenant and `access-pass set` is the only +//! instruction that writes `tenant_allowlist` — setting it is inherently a replace, so a list +//! would be misleading. +//! +//! Unknown keys are rejected, and the payer is resolved separately from parsing so a document can +//! be validated without a keypair or a network connection. + +use serde::{Deserialize, Serialize}; +use solana_sdk::pubkey::Pubkey; +use std::{collections::HashSet, net::Ipv4Addr, path::Path, str::FromStr}; + +// `deny_unknown_fields` on every type here is load-bearing rather than tidiness: each optional +// field is read with a default, so `subscibe:` would otherwise parse as valid YAML, contribute +// nothing, and leave the host quietly unsubscribed. +#[derive(Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AccessPassDocument { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub access_passes: Vec, +} + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Defaults { + /// Applied to any entry that omits `user_payer`. A fleet shares one payer, so this is + /// usually the only place it appears. + #[serde(default)] + pub user_payer: Option, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AccessPassEntry { + pub client_ip: Ipv4Addr, + #[serde(default)] + pub user_payer: Option, + /// Tenant code granting IBRL (unicast) access. One code, or omitted for none. + #[serde(default)] + pub ibrl: Option, + #[serde(default)] + pub multicast: Multicast, +} + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Multicast { + #[serde(default)] + pub publish: Vec, + #[serde(default)] + pub subscribe: Vec, +} + +/// One document entry with its payer resolved and its group lists deduplicated. +#[derive(Debug, Clone, PartialEq)] +pub struct DesiredAccessPass { + pub client_ip: Ipv4Addr, + pub user_payer: Pubkey, + pub ibrl: Option, + pub publish: Vec, + pub subscribe: Vec, +} + +impl AccessPassDocument { + pub fn from_path(path: &Path) -> eyre::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| eyre::eyre!("could not read {}: {e}", path.display()))?; + Self::from_yaml(&raw) + } + + pub fn from_yaml(raw: &str) -> eyre::Result { + serde_yaml::from_str(raw).map_err(|e| eyre::eyre!("invalid access-pass document: {e}")) + } + + /// Resolves every entry's payer and normalizes its group lists. + /// + /// `payer` is the current signer, used for the literal `"me"`. Resolution happens here rather + /// than at parse time so the document stays a pure value that can be validated without a + /// keypair or a network connection. + pub fn resolve(&self, payer: Pubkey) -> eyre::Result> { + let mut resolved = Vec::with_capacity(self.access_passes.len()); + let mut seen: HashSet<(Ipv4Addr, Pubkey)> = HashSet::new(); + + for (index, entry) in self.access_passes.iter().enumerate() { + let raw_payer = entry + .user_payer + .as_deref() + .or(self.defaults.user_payer.as_deref()) + .ok_or_else(|| { + eyre::eyre!( + "access_passes[{index}] ({}) has no user_payer and defaults.user_payer is unset", + entry.client_ip + ) + })?; + + let user_payer = if raw_payer.eq_ignore_ascii_case("me") { + payer + } else { + Pubkey::from_str(raw_payer).map_err(|_| { + eyre::eyre!( + "access_passes[{index}] ({}) has an invalid user_payer: {raw_payer}", + entry.client_ip + ) + })? + }; + + // The pass is a PDA of (client_ip, user_payer), so two entries naming the same pair + // describe the same account. Declarative lists make that a contradiction rather than + // a merge: whichever entry ran last would revoke the other's groups. + if !seen.insert((entry.client_ip, user_payer)) { + eyre::bail!( + "access_passes[{index}]: {} / {user_payer} is declared more than once", + entry.client_ip + ); + } + + // An empty string is a declaration mistake rather than "no tenant": omitting the + // key is how you say that, and a blank value would otherwise resolve to no tenant and + // silently revoke unicast access. + if entry.ibrl.as_deref().is_some_and(|t| t.trim().is_empty()) { + eyre::bail!( + "access_passes[{index}] ({}) has an empty ibrl; omit the key to declare no tenant", + entry.client_ip + ); + } + + resolved.push(DesiredAccessPass { + client_ip: entry.client_ip, + user_payer, + ibrl: entry.ibrl.clone(), + publish: dedupe(&entry.multicast.publish), + subscribe: dedupe(&entry.multicast.subscribe), + }); + } + + Ok(resolved) + } +} + +fn dedupe(codes: &[String]) -> Vec { + let mut seen = HashSet::new(); + codes + .iter() + .filter(|code| seen.insert(code.as_str())) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::{AccessPassDocument, DesiredAccessPass}; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn parses_a_document_and_applies_the_default_payer() { + let payer = Pubkey::new_unique(); + let other = Pubkey::new_unique(); + let doc = AccessPassDocument::from_yaml(&format!( + r#" +defaults: + user_payer: {payer} + +access_passes: + - client_ip: 203.0.113.10 + multicast: + publish: + - perps-tob + subscribe: + - perps-mbp + - client_ip: 203.0.113.11 + user_payer: {other} + multicast: + subscribe: + - pol-mbp +"# + )) + .unwrap(); + + assert_eq!( + doc.resolve(Pubkey::new_unique()).unwrap(), + vec![ + DesiredAccessPass { + client_ip: [203, 0, 113, 10].into(), + user_payer: payer, + ibrl: None, + publish: vec!["perps-tob".to_string()], + subscribe: vec!["perps-mbp".to_string()], + }, + DesiredAccessPass { + client_ip: [203, 0, 113, 11].into(), + user_payer: other, + ibrl: None, + publish: vec![], + subscribe: vec!["pol-mbp".to_string()], + }, + ] + ); + } + + #[test] + fn resolves_me_to_the_current_payer() { + let payer = Pubkey::new_unique(); + let doc = AccessPassDocument::from_yaml( + r#" +access_passes: + - client_ip: 203.0.113.10 + user_payer: me + multicast: + subscribe: [pol-mbp] +"#, + ) + .unwrap(); + + assert_eq!(doc.resolve(payer).unwrap()[0].user_payer, payer); + } + + #[test] + fn an_entry_with_no_multicast_block_declares_no_groups() { + // Not a parse error: it is a legitimate declaration that revokes everything, and the + // revocations show up in the plan for review before anything is sent. + let payer = Pubkey::new_unique(); + let doc = AccessPassDocument::from_yaml(&format!( + "defaults:\n user_payer: {payer}\naccess_passes:\n - client_ip: 203.0.113.10\n" + )) + .unwrap(); + + let resolved = doc.resolve(payer).unwrap(); + assert!(resolved[0].publish.is_empty()); + assert!(resolved[0].subscribe.is_empty()); + } + + #[test] + fn rejects_a_misspelled_key() { + // The whole point of deny_unknown_fields: `subscibe` would otherwise default to empty and + // silently unsubscribe the host. + let err = AccessPassDocument::from_yaml( + r#" +access_passes: + - client_ip: 203.0.113.10 + user_payer: me + multicast: + subscibe: [pol-mbp] +"#, + ) + .unwrap_err(); + assert!(err.to_string().contains("subscibe"), "{err}"); + } + + #[test] + fn rejects_an_unknown_top_level_key() { + let err = AccessPassDocument::from_yaml("acces_passes: []").unwrap_err(); + assert!(err.to_string().contains("acces_passes"), "{err}"); + } + + #[test] + fn rejects_an_entry_with_no_payer_anywhere() { + let doc = + AccessPassDocument::from_yaml("access_passes:\n - client_ip: 203.0.113.10\n").unwrap(); + let err = doc.resolve(Pubkey::new_unique()).unwrap_err(); + assert!(err.to_string().contains("no user_payer"), "{err}"); + } + + #[test] + fn rejects_a_malformed_payer() { + let doc = AccessPassDocument::from_yaml( + "access_passes:\n - client_ip: 203.0.113.10\n user_payer: not_a_pubkey\n", + ) + .unwrap(); + let err = doc.resolve(Pubkey::new_unique()).unwrap_err(); + assert!(err.to_string().contains("invalid user_payer"), "{err}"); + } + + #[test] + fn rejects_a_malformed_client_ip() { + let err = AccessPassDocument::from_yaml( + "access_passes:\n - client_ip: 203.0.113\n user_payer: me\n", + ) + .unwrap_err(); + assert!(err.to_string().contains("client_ip"), "{err}"); + } + + #[test] + fn rejects_the_same_pass_declared_twice() { + let payer = Pubkey::new_unique(); + let doc = AccessPassDocument::from_yaml(&format!( + r#" +defaults: + user_payer: {payer} +access_passes: + - client_ip: 203.0.113.10 + multicast: + subscribe: [a] + - client_ip: 203.0.113.10 + multicast: + subscribe: [b] +"# + )) + .unwrap(); + let err = doc.resolve(payer).unwrap_err(); + assert!(err.to_string().contains("declared more than once"), "{err}"); + } + + #[test] + fn deduplicates_repeated_group_codes() { + let payer = Pubkey::new_unique(); + let doc = AccessPassDocument::from_yaml(&format!( + "defaults:\n user_payer: {payer}\naccess_passes:\n - client_ip: 203.0.113.10\n multicast:\n subscribe: [a, a, b]\n" + )) + .unwrap(); + assert_eq!( + doc.resolve(payer).unwrap()[0].subscribe, + vec!["a".to_string(), "b".to_string()] + ); + } +} diff --git a/smartcontract/cli/src/accesspass/mod.rs b/smartcontract/cli/src/accesspass/mod.rs index 3c700a9c57..0dde583f8c 100644 --- a/smartcontract/cli/src/accesspass/mod.rs +++ b/smartcontract/cli/src/accesspass/mod.rs @@ -1,7 +1,10 @@ +pub mod apply; pub mod close; +pub mod desired; pub mod dzf_lock; pub mod fund; pub mod get; pub mod list; +pub mod plan; pub mod set; pub mod user_balances; diff --git a/smartcontract/cli/src/accesspass/plan.rs b/smartcontract/cli/src/accesspass/plan.rs new file mode 100644 index 0000000000..be2b40caa2 --- /dev/null +++ b/smartcontract/cli/src/accesspass/plan.rs @@ -0,0 +1,1082 @@ +//! Diffs an access-pass definition document against the ledger, and renders the result. +//! +//! Produces the allowlist grants and revokes, and the IBRL (tenant) changes, that would bring the +//! ledger to the state the document describes — plus the declared grants already satisfied, the +//! entries it refuses to act on, and any warnings. `plan` prints that; `apply` prints it and then +//! sends it. + +use crate::{ + accesspass::desired::{AccessPassDocument, DesiredAccessPass}, + doublezerocommand::CliCommand, +}; +use clap::Args; +use doublezero_cli_core::CliContext; +use doublezero_sdk::commands::{ + accesspass::get::GetAccessPassCommand, feed::list::ListFeedCommand, + multicastgroup::list::ListMulticastGroupCommand, tenant::list::ListTenantCommand, +}; +use doublezero_serviceability::state::accesspass::AccessPass; +use serde::Serialize; +use solana_sdk::pubkey::Pubkey; +use std::{ + collections::{BTreeSet, HashMap}, + io::Write, + net::Ipv4Addr, + path::PathBuf, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Publisher, + Subscriber, + /// Unicast access, granted by the pass's tenant rather than a multicast allowlist. + Ibrl, +} + +impl Role { + pub fn label(self) -> &'static str { + match self { + Role::Publisher => "publisher", + Role::Subscriber => "subscriber", + Role::Ibrl => "ibrl", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Op { + Grant, + Revoke, +} + +/// One allowlist write the plan would make. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct PlannedChange { + pub client_ip: Ipv4Addr, + #[serde(serialize_with = "ser_pubkey")] + pub user_payer: Pubkey, + #[serde(serialize_with = "ser_pubkey")] + pub access_pass: Pubkey, + pub group: String, + pub role: Role, + pub op: Op, +} + +/// A change to the pass's IBRL (unicast) grant: its tenant, and the epoch that gates it. +/// +/// The tenant and `last_access_epoch` are one grant, written by the same `access-pass set`. A +/// declared `ibrl` means the epoch must be unlimited: the epoch gates unicast user creation only, +/// and any finite value turns a later `connect ibrl` on this IP into a failure at an unpredictable +/// date. Either half drifting re-sends the same instruction. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct IbrlChange { + pub client_ip: Ipv4Addr, + #[serde(serialize_with = "ser_pubkey")] + pub user_payer: Pubkey, + #[serde(serialize_with = "ser_pubkey")] + pub access_pass: Pubkey, + /// Tenant code currently on the pass, if any. + pub from: Option, + /// Tenant code the document declares, or `None` to clear the grant. + pub to: Option, + /// `to` resolved to its account, or the default pubkey to clear. Skipped in JSON: the code is + /// what a reader wants, and the key is an implementation detail of the write. + #[serde(skip)] + pub to_pk: Pubkey, + /// The epoch is not already unlimited, so this write moves it too. + pub epoch_drift: bool, +} + +/// A declared grant that is already satisfied, and by what. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SatisfiedGrant { + pub client_ip: Ipv4Addr, + pub group: String, + pub role: Role, + /// `allowlist`, or `feed ` when an EdgeSeat feed already grants it. + pub source: String, +} + +/// Something the plan refuses to do, and why. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BlockedItem { + pub client_ip: Ipv4Addr, + #[serde(serialize_with = "ser_pubkey")] + pub user_payer: Pubkey, + pub reason: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize)] +pub struct AccessPassPlan { + pub changes: Vec, + pub ibrl_changes: Vec, + pub satisfied: Vec, + pub blocked: Vec, + pub warnings: Vec, +} + +fn ser_pubkey(pk: &Pubkey, s: S) -> Result { + s.serialize_str(&pk.to_string()) +} + +impl AccessPassPlan { + pub fn grants(&self) -> usize { + self.changes.iter().filter(|c| c.op == Op::Grant).count() + } + + pub fn revokes(&self) -> usize { + self.changes.iter().filter(|c| c.op == Op::Revoke).count() + } + + pub fn is_empty(&self) -> bool { + self.changes.is_empty() && self.ibrl_changes.is_empty() + } + + /// Total writes the plan would make, across both instruction kinds. + pub fn write_count(&self) -> usize { + self.changes.len() + self.ibrl_changes.len() + } +} + +/// Tenant codes in both directions, read once per run. +struct TenantCodes { + by_code: HashMap, + by_pubkey: HashMap, +} + +impl TenantCodes { + fn read(client: &C) -> eyre::Result { + let tenants = client.list_tenant(ListTenantCommand {})?; + Ok(Self { + by_code: tenants + .iter() + .map(|(pk, t)| (t.code.clone(), *pk)) + .collect(), + by_pubkey: tenants + .iter() + .map(|(pk, t)| (*pk, t.code.clone())) + .collect(), + }) + } +} + +/// Builds the diff between the document and the ledger. +/// +/// Reads are batched where the account model allows: the multicast groups and the feeds are each +/// one scan for the whole document, and only the access passes are fetched per entry, because +/// each is a distinct PDA. +pub fn build_plan( + client: &C, + desired: &[DesiredAccessPass], +) -> eyre::Result { + let mgroups = client.list_multicastgroup(ListMulticastGroupCommand {})?; + let code_to_pk: HashMap<&str, Pubkey> = mgroups + .iter() + .map(|(pk, mg)| (mg.code.as_str(), *pk)) + .collect(); + let pk_to_code: HashMap = mgroups + .iter() + .map(|(pk, mg)| (*pk, mg.code.as_str())) + .collect(); + + // A code the program cannot resolve becomes a grant pointing at nothing rather than an error, + // so every code in the document is checked before anything is planned. Report all of them at + // once: fixing a typo only to hit the next one is a poor way to spend a round trip. + let unknown: BTreeSet<&str> = desired + .iter() + .flat_map(|d| d.publish.iter().chain(d.subscribe.iter())) + .map(String::as_str) + .filter(|code| !code_to_pk.contains_key(code)) + .collect(); + if !unknown.is_empty() { + eyre::bail!( + "unknown multicast group code(s): {}", + unknown.into_iter().collect::>().join(", ") + ); + } + + // Tenants are read when the document declares an `ibrl`, and otherwise only once a pass turns + // out to carry a tenant — a pass with one still has to be cleared, so the scan cannot be + // skipped merely because the document is silent. `None` means "not read yet". + let mut tenants: Option = None; + if desired.iter().any(|d| d.ibrl.is_some()) { + let read = TenantCodes::read(client)?; + // Codes are checked for the same reason group codes are: the program resolves a code to a + // PDA without checking anything is behind it, so a typo writes a grant pointing at nothing. + let unknown: BTreeSet<&str> = desired + .iter() + .filter_map(|d| d.ibrl.as_deref()) + .filter(|code| !read.by_code.contains_key(*code)) + .collect(); + if !unknown.is_empty() { + eyre::bail!( + "unknown tenant code(s): {}", + unknown.into_iter().collect::>().join(", ") + ); + } + tenants = Some(read); + } + + let mut plan = AccessPassPlan::default(); + let mut feeds: Option> = None; + + for entry in desired { + let Some((pass_pk, pass)) = client.get_accesspass(GetAccessPassCommand { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + })? + else { + plan.blocked.push(BlockedItem { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + reason: "no access pass at this PDA — create it with `doublezero access-pass set`" + .to_string(), + }); + continue; + }; + + // Access-pass resolution prefers a pass stored at the 0.0.0.0 PDA, which is valid for any + // client IP. So a lookup for a concrete address can legitimately land on a shared pass — + // and every group granted here is then granted to every host using that pass. + if pass.client_ip != entry.client_ip { + plan.warnings.push(format!( + "{} resolved to the shared access pass at {} ({pass_pk}); \ + changes here affect every host using it", + entry.client_ip, pass.client_ip + )); + } + + // Only an EdgeSeat pass carries feeds, so a document of ordinary passes never pays for + // the scan. + if feeds.is_none() && !pass.feed_seats().is_empty() { + feeds = Some(client.list_feed(ListFeedCommand)?); + } + let feed_granted = feed_granted_groups(&pass, feeds.as_ref(), &pk_to_code); + + let have_pub = allowlist_codes(&pass.mgroup_pub_allowlist, &pk_to_code); + let have_sub = allowlist_codes(&pass.mgroup_sub_allowlist, &pk_to_code); + let want_pub: BTreeSet<&str> = entry.publish.iter().map(String::as_str).collect(); + let want_sub: BTreeSet<&str> = entry.subscribe.iter().map(String::as_str).collect(); + + // A group leaving both allowlists at once cannot be revoked safely. The host's detach + // verbs send the role being KEPT as desired state, and the program authorizes every + // `true` against these allowlists — so once both entries are gone, `multicast unpublish` + // asks for subscriber=true and `multicast unsubscribe` asks for publisher=true, and + // neither is allowlisted any more. The roles are then stranded on the User with no legal + // write to remove them. Detach the host first, then revoke. + let dual_revoke: Vec<&str> = have_pub + .difference(&want_pub) + .filter(|code| have_sub.difference(&want_sub).any(|s| s == *code)) + .copied() + .collect(); + if !dual_revoke.is_empty() { + plan.blocked.push(BlockedItem { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + reason: format!( + "{} would leave both allowlists at once; detach the host \ + (`doublezero multicast unpublish` / `unsubscribe`) before revoking both roles", + dual_revoke.join(", ") + ), + }); + continue; + } + + // The pass admits one tenant, so the grant is its first entry. + if tenants.is_none() && !pass.tenant_allowlist.is_empty() { + tenants = Some(TenantCodes::read(client)?); + } + let have_tenant = pass.tenant_allowlist.first().and_then(|pk| { + tenants + .as_ref() + .and_then(|t| t.by_pubkey.get(pk)) + .map(String::as_str) + }); + let want_tenant = entry.ibrl.as_deref(); + // A declared ibrl also requires an unlimited epoch. Zero is not "expired" but "no epoch + // defined", and blocks every unicast type outright, so a pass left that way fails + // `connect ibrl` on the host long after this reported success. + let epoch_drift = want_tenant.is_some() && pass.last_access_epoch != u64::MAX; + + if have_tenant != want_tenant || epoch_drift { + plan.ibrl_changes.push(IbrlChange { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + access_pass: pass_pk, + from: have_tenant.map(str::to_string), + to: want_tenant.map(str::to_string), + to_pk: want_tenant + .and_then(|code| tenants.as_ref().and_then(|t| t.by_code.get(code).copied())) + .unwrap_or_default(), + epoch_drift, + }); + } else if want_tenant.is_some() { + plan.satisfied.push(SatisfiedGrant { + client_ip: entry.client_ip, + group: want_tenant.unwrap_or_default().to_string(), + role: Role::Ibrl, + source: "tenant_allowlist".to_string(), + }); + } + + for (role, want, have) in [ + (Role::Publisher, &want_pub, &have_pub), + (Role::Subscriber, &want_sub, &have_sub), + ] { + for code in want.difference(have) { + // A feed already grants subscribe on its groups in its own metro, so granting it + // again spends a transaction and changes nothing. Publisher is never feed-covered. + if role == Role::Subscriber { + if let Some(feed_code) = feed_granted.get(*code) { + plan.satisfied.push(SatisfiedGrant { + client_ip: entry.client_ip, + group: (*code).to_string(), + role, + source: format!("feed {feed_code}"), + }); + continue; + } + } + plan.changes.push(PlannedChange { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + access_pass: pass_pk, + group: (*code).to_string(), + role, + op: Op::Grant, + }); + } + + for code in want.intersection(have) { + plan.satisfied.push(SatisfiedGrant { + client_ip: entry.client_ip, + group: (*code).to_string(), + role, + source: "allowlist".to_string(), + }); + } + + for code in have.difference(want) { + plan.changes.push(PlannedChange { + client_ip: entry.client_ip, + user_payer: entry.user_payer, + access_pass: pass_pk, + group: (*code).to_string(), + role, + op: Op::Revoke, + }); + } + } + } + + Ok(plan) +} + +fn allowlist_codes<'a>( + allowlist: &[Pubkey], + pk_to_code: &HashMap, +) -> BTreeSet<&'a str> { + // A key with no group is a grant pointing at a deleted group. It cannot be named, and the + // document cannot declare it, so leaving it out of the "have" set would plan a revoke the + // operator cannot read. Skipping it leaves it untouched instead. + allowlist + .iter() + .filter_map(|pk| pk_to_code.get(pk).copied()) + .collect() +} + +/// Group code -> the feed that grants subscribe on it, for the feeds seated on this pass. +fn feed_granted_groups<'a>( + pass: &AccessPass, + feeds: Option<&HashMap>, + pk_to_code: &HashMap, +) -> HashMap<&'a str, String> { + let mut granted = HashMap::new(); + let Some(feeds) = feeds else { + return granted; + }; + for seat in pass.feed_seats() { + let Some(feed) = feeds.get(&seat.feed_key) else { + continue; + }; + for group in &feed.groups { + if let Some(code) = pk_to_code.get(group) { + granted.entry(*code).or_insert_with(|| feed.code.clone()); + } + } + } + granted +} + +/// Renders the plan the way `terraform plan` does: the actions, then a one-line summary. +pub fn render_plan( + out: &mut W, + plan: &AccessPassPlan, + verbose: bool, +) -> eyre::Result<()> { + if plan.is_empty() && plan.blocked.is_empty() { + writeln!(out, "No changes. The ledger matches the document.")?; + } else if !plan.changes.is_empty() { + writeln!(out, "DoubleZero will perform the following actions:")?; + writeln!(out)?; + + let mut current: Option<(Ipv4Addr, Pubkey)> = None; + for change in &plan.changes { + let key = (change.client_ip, change.user_payer); + if current != Some(key) { + if current.is_some() { + writeln!(out)?; + } + writeln!( + out, + " # access_pass {} / {} ({})", + change.client_ip, change.user_payer, change.access_pass + )?; + current = Some(key); + } + let sign = match change.op { + Op::Grant => '+', + Op::Revoke => '-', + }; + writeln!( + out, + " {sign} {:<10} {}", + change.role.label(), + change.group + )?; + } + writeln!(out)?; + } + + if !plan.ibrl_changes.is_empty() { + writeln!(out, "IBRL (unicast) access:")?; + writeln!(out)?; + for change in &plan.ibrl_changes { + writeln!( + out, + " # access_pass {} / {} ({})", + change.client_ip, change.user_payer, change.access_pass + )?; + match (&change.from, &change.to) { + (None, Some(to)) => writeln!(out, " + ibrl {to}")?, + (Some(from), None) => writeln!(out, " - ibrl {from}")?, + (Some(from), Some(to)) => writeln!(out, " ~ ibrl {from} -> {to}")?, + // Only reachable when the tenant already matches and the epoch is what moved. + (None, None) => writeln!(out, " ~ ibrl (epoch only)")?, + } + if change.epoch_drift { + writeln!(out, " ~ epoch -> unlimited")?; + } + } + writeln!(out)?; + } + + if !plan.blocked.is_empty() { + writeln!(out, "Blocked:")?; + for item in &plan.blocked { + writeln!(out, " ! {} / {}", item.client_ip, item.user_payer)?; + writeln!(out, " {}", item.reason)?; + } + writeln!(out)?; + } + + if verbose && !plan.satisfied.is_empty() { + writeln!(out, "Already satisfied ({}):", plan.satisfied.len())?; + for item in &plan.satisfied { + writeln!( + out, + " {} {:<10} {:<28} {}", + item.client_ip, + item.role.label(), + item.group, + item.source + )?; + } + writeln!(out)?; + } + + if !plan.warnings.is_empty() { + writeln!(out, "Warnings:")?; + for warning in &plan.warnings { + writeln!(out, " {warning}")?; + } + writeln!(out)?; + } + + writeln!( + out, + "Plan: {} to add, {} to remove, {} IBRL change(s), {} satisfied, {} blocked.", + plan.grants(), + plan.revokes(), + plan.ibrl_changes.len(), + plan.satisfied.len(), + plan.blocked.len() + )?; + + Ok(()) +} + +/// Reads an access-pass definition document and reports what would change, writing nothing. +#[derive(Args, Debug)] +pub struct PlanAccessPassCliCommand { + /// Path to the access-pass definition document (YAML) + #[arg(long, short = 'f')] + pub file: PathBuf, + /// Also list the grants that are already satisfied + #[arg(long, default_value_t = false)] + pub verbose: bool, + /// Output as pretty JSON + #[arg(long, default_value_t = false)] + pub json: bool, +} + +impl PlanAccessPassCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + // No requirement check: this reads the ledger and writes nothing, so it must work for an + // operator who holds no keypair and no admin permission. + let document = AccessPassDocument::from_path(&self.file)?; + let desired = document.resolve(client.get_payer())?; + let plan = build_plan(client, &desired)?; + + if self.json { + #[derive(Serialize)] + struct PlanJson<'a> { + changed: bool, + #[serde(flatten)] + plan: &'a AccessPassPlan, + } + let json = serde_json::to_string_pretty(&PlanJson { + changed: !plan.is_empty(), + plan: &plan, + })?; + writeln!(out, "{json}")?; + } else { + render_plan(out, &plan, self.verbose)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{build_plan, render_plan, Op, Role}; + use crate::{accesspass::desired::DesiredAccessPass, tests::utils::create_test_client}; + use doublezero_sdk::{AccountType, Feed, MulticastGroup, MulticastGroupStatus}; + use doublezero_serviceability::state::accesspass::{ + AccessPass, AccessPassStatus, AccessPassType, FeedSeat, + }; + use solana_sdk::pubkey::Pubkey; + use std::{collections::HashMap, net::Ipv4Addr}; + + const IP: [u8; 4] = [203, 0, 113, 10]; + + fn mgroup(code: &str) -> MulticastGroup { + MulticastGroup { + account_type: AccountType::MulticastGroup, + index: 1, + bump_seed: 1, + owner: Pubkey::new_unique(), + tenant_pk: Pubkey::default(), + multicast_ip: [239, 0, 0, 1].into(), + max_bandwidth: 1_000_000_000, + status: MulticastGroupStatus::Activated, + code: code.to_string(), + publisher_count: 0, + subscriber_count: 0, + } + } + + fn pass( + client_ip: Ipv4Addr, + user_payer: Pubkey, + pub_allow: Vec, + sub_allow: Vec, + ) -> AccessPass { + AccessPass { + account_type: AccountType::AccessPass, + bump_seed: 255, + accesspass_type: AccessPassType::Prepaid, + client_ip, + user_payer, + last_access_epoch: u64::MAX, + connection_count: 0, + status: AccessPassStatus::Connected, + mgroup_pub_allowlist: pub_allow, + mgroup_sub_allowlist: sub_allow, + tenant_allowlist: vec![], + owner: Pubkey::new_unique(), + flags: 0, + unicast_user_count: 0, + max_unicast_users: 1, + multicast_user_count: 0, + max_multicast_users: 1, + } + } + + fn tenant(code: &str) -> doublezero_serviceability::state::tenant::Tenant { + use doublezero_serviceability::state::tenant::{ + Tenant, TenantBillingConfig, TenantPaymentStatus, + }; + Tenant { + account_type: AccountType::Tenant, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: code.to_string(), + vrf_id: 100, + reference_count: 1, + administrators: vec![], + token_account: Pubkey::default(), + payment_status: TenantPaymentStatus::Paid, + metro_routing: false, + route_liveness: false, + billing: TenantBillingConfig::default(), + include_topologies: vec![], + } + } + + fn with_tenants( + client: &mut crate::doublezerocommand::MockCliCommand, + codes: &[&str], + ) -> HashMap { + let mut tenants = HashMap::new(); + let mut by_code = HashMap::new(); + for code in codes { + let pk = Pubkey::new_unique(); + tenants.insert(pk, tenant(code)); + by_code.insert((*code).to_string(), pk); + } + client + .expect_list_tenant() + .returning(move |_| Ok(tenants.clone())); + by_code + } + + fn desired_ibrl(payer: Pubkey, ibrl: Option<&str>) -> Vec { + vec![DesiredAccessPass { + client_ip: IP.into(), + user_payer: payer, + ibrl: ibrl.map(str::to_string), + publish: vec![], + subscribe: vec![], + }] + } + + fn desired(payer: Pubkey, publish: &[&str], subscribe: &[&str]) -> Vec { + vec![DesiredAccessPass { + client_ip: IP.into(), + user_payer: payer, + ibrl: None, + publish: publish.iter().map(|s| s.to_string()).collect(), + subscribe: subscribe.iter().map(|s| s.to_string()).collect(), + }] + } + + /// Mock `list_multicastgroup` with the given codes, returning the code -> pubkey map. + fn with_groups( + client: &mut crate::doublezerocommand::MockCliCommand, + codes: &[&str], + ) -> HashMap { + let mut groups = HashMap::new(); + let mut by_code = HashMap::new(); + for code in codes { + let pk = Pubkey::new_unique(); + groups.insert(pk, mgroup(code)); + by_code.insert((*code).to_string(), pk); + } + client + .expect_list_multicastgroup() + .returning(move |_| Ok(groups.clone())); + by_code + } + + #[test] + fn plans_the_missing_grants_and_the_undeclared_revokes() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g-keep", "g-add", "g-drop"]); + + // Pass currently subscribes to g-keep and g-drop; the document declares g-keep and g-add. + let existing = pass( + IP.into(), + payer, + vec![], + vec![by_code["g-keep"], by_code["g-drop"]], + ); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g-keep", "g-add"])).unwrap(); + + assert_eq!(plan.grants(), 1); + assert_eq!(plan.revokes(), 1); + let add = plan.changes.iter().find(|c| c.op == Op::Grant).unwrap(); + assert_eq!((add.group.as_str(), add.role), ("g-add", Role::Subscriber)); + let drop = plan.changes.iter().find(|c| c.op == Op::Revoke).unwrap(); + assert_eq!( + (drop.group.as_str(), drop.role), + ("g-drop", Role::Subscriber) + ); + // g-keep is already there and is reported as satisfied rather than re-granted. + assert!(plan + .satisfied + .iter() + .any(|s| s.group == "g-keep" && s.source == "allowlist")); + } + + #[test] + fn a_matching_document_plans_nothing() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g1"]); + + let existing = pass(IP.into(), payer, vec![by_code["g1"]], vec![by_code["g1"]]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &["g1"], &["g1"])).unwrap(); + + assert!(plan.is_empty(), "{:?}", plan.changes); + assert_eq!(plan.satisfied.len(), 2); + } + + #[test] + fn a_feed_granted_subscribe_is_satisfied_not_granted() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g-feed"]); + let group_pk = by_code["g-feed"]; + + let feed_key = Pubkey::new_unique(); + let mut edge = pass(IP.into(), payer, vec![], vec![]); + edge.accesspass_type = AccessPassType::EdgeSeat(vec![FeedSeat { + feed_key, + max_users: 1, + max_future_users: 1, + current_users: 0, + anniversary_day: 1, + window_end: 0, + terminates_at: 0, + }]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), edge.clone())))); + client.expect_list_feed().returning(move |_| { + Ok(HashMap::from([( + feed_key, + Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 1, + code: "example-feed".to_string(), + name: "QA payments".to_string(), + exchange: Pubkey::new_unique(), + groups: vec![group_pk], + }, + )])) + }); + + let plan = build_plan(&client, &desired(payer, &["g-feed"], &["g-feed"])).unwrap(); + + // Subscribe is already covered by the feed, so no transaction for it... + assert!(plan + .satisfied + .iter() + .any(|s| s.role == Role::Subscriber && s.source == "feed example-feed")); + // ...but a feed grants subscribe only, so publish is still a real gap. + assert_eq!(plan.grants(), 1); + assert_eq!(plan.changes[0].role, Role::Publisher); + } + + #[test] + fn a_missing_access_pass_is_blocked_rather_than_created() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &["g1"]); + client.expect_get_accesspass().returning(|_| Ok(None)); + + let plan = build_plan(&client, &desired(payer, &[], &["g1"])).unwrap(); + + assert!(plan.is_empty()); + assert_eq!(plan.blocked.len(), 1); + assert!(plan.blocked[0].reason.contains("no access pass")); + } + + #[test] + fn revoking_both_roles_of_one_group_is_blocked() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g-both"]); + + let existing = pass( + IP.into(), + payer, + vec![by_code["g-both"]], + vec![by_code["g-both"]], + ); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + // The document declares nothing, so g-both would leave both allowlists at once. + let plan = build_plan(&client, &desired(payer, &[], &[])).unwrap(); + + assert!( + plan.is_empty(), + "no writes may be planned: {:?}", + plan.changes + ); + assert_eq!(plan.blocked.len(), 1); + assert!(plan.blocked[0].reason.contains("both allowlists")); + } + + #[test] + fn an_unknown_group_code_fails_before_anything_is_planned() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &["g1"]); + + let err = build_plan(&client, &desired(payer, &[], &["g1", "typo", "worse"])).unwrap_err(); + + // Every bad code is named, so a fix does not have to be discovered one round trip at a time. + assert!(err.to_string().contains("typo"), "{err}"); + assert!(err.to_string().contains("worse"), "{err}"); + } + + #[test] + fn landing_on_the_shared_wildcard_pass_is_warned_about() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &["g1"]); + + // Resolution prefers the 0.0.0.0 pass, so a concrete IP can land on the shared one. + let shared = pass(Ipv4Addr::UNSPECIFIED, payer, vec![], vec![]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), shared.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g1"])).unwrap(); + + assert_eq!(plan.warnings.len(), 1); + assert!( + plan.warnings[0].contains("shared access pass"), + "{:?}", + plan.warnings + ); + } + + #[test] + fn a_group_that_no_longer_exists_is_left_alone() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g1"]); + + // A key with no group behind it cannot be named, so the document cannot declare it and + // planning a revoke for it would print an unreadable pubkey. + let existing = pass( + IP.into(), + payer, + vec![], + vec![by_code["g1"], Pubkey::new_unique()], + ); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g1"])).unwrap(); + + assert!(plan.is_empty(), "{:?}", plan.changes); + } + + #[test] + fn grants_the_declared_tenant_and_pins_the_epoch() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + let by_code = with_tenants(&mut client, &["solana"]); + + // No tenant, and an epoch that is not unlimited. + let mut existing = pass(IP.into(), payer, vec![], vec![]); + existing.last_access_epoch = 200; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired_ibrl(payer, Some("solana"))).unwrap(); + + assert_eq!(plan.ibrl_changes.len(), 1); + let change = &plan.ibrl_changes[0]; + assert_eq!(change.from, None); + assert_eq!(change.to.as_deref(), Some("solana")); + assert_eq!(change.to_pk, by_code["solana"]); + assert!(change.epoch_drift); + } + + #[test] + fn a_matching_tenant_with_an_unlimited_epoch_is_satisfied() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + let by_code = with_tenants(&mut client, &["solana"]); + + let mut existing = pass(IP.into(), payer, vec![], vec![]); + existing.tenant_allowlist = vec![by_code["solana"]]; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired_ibrl(payer, Some("solana"))).unwrap(); + + assert!(plan.is_empty(), "{:?}", plan.ibrl_changes); + assert!(plan + .satisfied + .iter() + .any(|s| s.role == Role::Ibrl && s.source == "tenant_allowlist")); + } + + #[test] + fn a_finite_epoch_alone_is_drift() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + let by_code = with_tenants(&mut client, &["solana"]); + + // The tenant is already right; only the epoch is wrong. Left unrepaired, `connect ibrl` + // on this host fails at an unpredictable date. + let mut existing = pass(IP.into(), payer, vec![], vec![]); + existing.tenant_allowlist = vec![by_code["solana"]]; + existing.last_access_epoch = 0; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired_ibrl(payer, Some("solana"))).unwrap(); + + assert_eq!(plan.ibrl_changes.len(), 1); + assert!(plan.ibrl_changes[0].epoch_drift); + assert_eq!(plan.ibrl_changes[0].to.as_deref(), Some("solana")); + } + + #[test] + fn an_omitted_ibrl_clears_the_tenant() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + let by_code = with_tenants(&mut client, &["solana"]); + + let mut existing = pass(IP.into(), payer, vec![], vec![]); + existing.tenant_allowlist = vec![by_code["solana"]]; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + // The document declares no ibrl, and every field here is declarative. + let mut want = desired_ibrl(payer, None); + want[0].subscribe = vec![]; + let plan = build_plan(&client, &want).unwrap(); + + assert_eq!(plan.ibrl_changes.len(), 1); + assert_eq!(plan.ibrl_changes[0].from.as_deref(), Some("solana")); + assert_eq!(plan.ibrl_changes[0].to, None); + assert_eq!(plan.ibrl_changes[0].to_pk, Pubkey::default()); + // Clearing does not touch the epoch. + assert!(!plan.ibrl_changes[0].epoch_drift); + } + + #[test] + fn an_unknown_tenant_code_fails_before_anything_is_planned() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + with_tenants(&mut client, &["solana"]); + + let err = build_plan(&client, &desired_ibrl(payer, Some("solanaa"))).unwrap_err(); + assert!(err.to_string().contains("solanaa"), "{err}"); + } + + #[test] + fn a_document_with_no_ibrl_skips_the_tenant_scan_when_no_pass_has_one() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g1"]); + client.expect_list_tenant().never(); + + let existing = pass(IP.into(), payer, vec![], vec![by_code["g1"]]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g1"])).unwrap(); + assert!(plan.is_empty()); + } + + #[test] + fn a_document_with_no_ibrl_still_reads_tenants_when_a_pass_carries_one() { + // The scan cannot be skipped merely because the document is silent: a pass with a tenant + // still has to be cleared, and naming it in the plan needs its code. + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + with_groups(&mut client, &[]); + let by_code = with_tenants(&mut client, &["solana"]); + + let mut existing = pass(IP.into(), payer, vec![], vec![]); + existing.tenant_allowlist = vec![by_code["solana"]]; + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &[])).unwrap(); + + assert_eq!(plan.ibrl_changes.len(), 1); + assert_eq!(plan.ibrl_changes[0].from.as_deref(), Some("solana")); + } + + #[test] + fn render_reports_no_changes_when_the_ledger_matches() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g1"]); + let existing = pass(IP.into(), payer, vec![], vec![by_code["g1"]]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g1"])).unwrap(); + let mut out = Vec::new(); + render_plan(&mut out, &plan, false).unwrap(); + + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("No changes."), "{text}"); + assert!(text.contains("Plan: 0 to add, 0 to remove"), "{text}"); + } + + #[test] + fn render_shows_the_actions_grouped_by_pass() { + let mut client = create_test_client(); + let payer = Pubkey::new_unique(); + let by_code = with_groups(&mut client, &["g-add", "g-drop"]); + let existing = pass(IP.into(), payer, vec![], vec![by_code["g-drop"]]); + client + .expect_get_accesspass() + .returning(move |_| Ok(Some((Pubkey::new_unique(), existing.clone())))); + + let plan = build_plan(&client, &desired(payer, &[], &["g-add"])).unwrap(); + let mut out = Vec::new(); + render_plan(&mut out, &plan, false).unwrap(); + + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("# access_pass 203.0.113.10"), "{text}"); + assert!(text.contains("+ subscriber g-add"), "{text}"); + assert!(text.contains("- subscriber g-drop"), "{text}"); + assert!(text.contains("Plan: 1 to add, 1 to remove"), "{text}"); + } +} diff --git a/smartcontract/cli/src/cli/accesspass.rs b/smartcontract/cli/src/cli/accesspass.rs index 6f2d144203..eaadf39cc9 100644 --- a/smartcontract/cli/src/cli/accesspass.rs +++ b/smartcontract/cli/src/cli/accesspass.rs @@ -1,9 +1,11 @@ use crate::accesspass::{ + apply::ApplyAccessPassCliCommand, close::CloseAccessPassCliCommand, dzf_lock::{DzfLockAccessPassCliCommand, DzfUnlockAccessPassCliCommand}, fund::FundAccessPassCliCommand, get::GetAccessPassCliCommand, list::ListAccessPassCliCommand, + plan::PlanAccessPassCliCommand, set::SetAccessPassCliCommand, user_balances::UserBalancesAccessPassCliCommand, }; @@ -20,6 +22,12 @@ pub enum AccessPassCommands { /// Set access pass #[clap()] Set(SetAccessPassCliCommand), + /// Show what a definition document would change, without writing + #[clap()] + Plan(PlanAccessPassCliCommand), + /// Converge the ledger onto a definition document + #[clap()] + Apply(ApplyAccessPassCliCommand), /// Close access pass #[clap()] Close(CloseAccessPassCliCommand), diff --git a/smartcontract/cli/src/cli/command.rs b/smartcontract/cli/src/cli/command.rs index 30cde40ab0..a338ceb12c 100644 --- a/smartcontract/cli/src/cli/command.rs +++ b/smartcontract/cli/src/cli/command.rs @@ -253,6 +253,11 @@ impl ServiceabilityCommand { }, Self::AccessPass(cmd) => match cmd.command { AccessPassCommands::Set(args) => args.execute(ctx, client, out).await, + AccessPassCommands::Plan(args) => args.execute(ctx, client, out).await, + AccessPassCommands::Apply(args) => { + args.execute(ctx, client, out, &mut std::io::stdin().lock()) + .await + } AccessPassCommands::Close(args) => args.execute(ctx, client, out).await, AccessPassCommands::List(args) => args.execute(ctx, client, out).await, AccessPassCommands::Get(args) => args.execute(ctx, client, out).await,