From 7413d8f67f77aaac7d906ae425154d1d0bf397ca Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 23 Sep 2026 13:33:25 -0400 Subject: [PATCH] docs(plan): propose ledger entries for ending logins and revoking tokens Add operator-ledger, a design for the owner's review, for issue 158. It weighs three options and recommends two events in the account's own ledger, with their shapes, the state machine, the move to layout 19, the tests and five questions. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: Iee181a95040174edd2b15c255d4284a7058e4062 --- plan/README.md | 1 + plan/operator-ledger.md | 304 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 plan/operator-ledger.md diff --git a/plan/README.md b/plan/README.md index edb4f065..fb3a9187 100644 --- a/plan/README.md +++ b/plan/README.md @@ -175,6 +175,7 @@ The exit criterion is met and something is still open in the file. | [name-pools](name-pools.md) | A zone is a pool with a role, and names leave it only when its role allows | open | | [onboarding](onboarding.md) | An operator establishes a server before the server can establish anyone else | open | | [onboarding-policies](onboarding-policies.md) | A new operator is shown policies worth having, not an empty ruleset | open | +| [operator-ledger](operator-ledger.md) | The ledger says which operator ended an app's logins or an account's tokens | open | | [periodic-backups](periodic-backups.md) | The server backs up its own data, and something reads it back | open | | [policy](policy.md) | What an agent may do is a set of denials, evaluated at the write, from three sources | open | | [policy-exemptions](policy-exemptions.md) | A policy can leave someone out without anyone being able to let themselves in | open | diff --git a/plan/operator-ledger.md b/plan/operator-ledger.md new file mode 100644 index 00000000..d2bb60db --- /dev/null +++ b/plan/operator-ledger.md @@ -0,0 +1,304 @@ +--- +id: operator-ledger +title: The ledger says which operator ended an app's logins or an account's tokens +status: open +crates: [didbot-pds, didbot-serve, didbot-operator, didbot-lexicon] +dependsOn: [] +exitCriterion: > + Ending an app's logins and revoking one account's tokens each append an + entry naming the operator to the ledger of every account they reach. The + dashboard has one route and `didbot account` one verb for the revocation, + and a test reads each entry back after a restart. +--- + +# operator-ledger + +Ending an app's logins records who did it in one log line, and nowhere that +lasts. The only ways an operator ends one account's tokens are erasing it and +the e-stop's Revoke, which reaches every account +([issue 158](https://tangled.org/did:plc:swttlkbjvcoln67iievud7b3/issues/158)). + +## What is recorded now + +| Action | What is written | Where | What it names | Lasts | +|---|---|---|---|---| +| End an app's logins | each login's refresh digest set to null | `pds.grants` | the app and the account, not who or when | until the login would have ended (14 days at most), or the file is deleted | +| End an app's logins | one `info` line | the host's log | the operator, the app, the account, the counts and the time | as long as the host keeps its log | +| End an account's tokens, in an erasure or an e-stop release | `accountTokenRevoked { did, tokenHash: null }` | the write-ahead log | the account, not who or when | until the next checkpoint, which rewrites live tokens only | + +## What the specs and the reference implementation do + +- The [atproto OAuth spec](https://atproto.com/specs/oauth) says "Auth sessions + can be revoked - invalidating the refresh tokens". It asks an authorization + server for "a web interface which allows authenticated users to view active + OAuth sessions and delete them". An account here has no person to use that + page, so that page is the operator's dashboard. +- The [Lexicon spec](https://atproto.com/specs/lexicon) says "Any new fields + must be optional", and that `knownValues` is "not a closed enum". Two new + event names and one optional field on `bot.did.getAccountLedger` fit both + rules. +- The reference PDS + ([`account-manager.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/account-manager/account-manager.ts)) + ends different tokens for different actions. `takedownAccount` ends the + account's refresh tokens and its OAuth tokens in one transaction. + `updateAccountPassword`, which `com.atproto.admin.updateAccountPassword` + calls, ends the refresh tokens and keeps the OAuth tokens. +- The reference OAuth provider's `revoke` + ([`oauth-provider.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts)) + deletes the token and records nothing. +- Ozone keeps a moderator's action as an event with `createdBy` (a DID), + `subject` and `createdAt`, in one log read by subject + ([`modEventView`](https://github.com/bluesky-social/atproto/blob/main/lexicons/tools/ozone/moderation/defs.json)). + The events below carry the same three facts: who, which account, when. + +The spec and the reference agree on everything this design relies on. + +## Option one: do nothing + +- **Stores:** nothing new. +- **Costs:** nothing. +- **Breaks:** nothing. The log line stays the only record of who ended an + app's logins. A leaked account token ends when the account is erased, or + when the e-stop's Revoke is released, which ends every account's tokens. + +## Option two: the account's own ledger + +- **Stores:** two ledger events, `loginsEnded` and `tokensRevoked`, one entry + in the ledger of each account an action reached. Every checkpoint keeps + them, and they outlive the account, as every ledger entry does. +- **Costs:** the layout moves from 18 to 19 (see "The layout move"). Ending an + app signed in on N accounts appends N synced entries, the cost + `FileAccountTokenStore::revoke_all` already pays per account. Two + `knownValues` and one optional field on `bot.did.getAccountLedger#entry`. + One route and one verb. +- **Breaks:** an image at 19 rewrites an 18 directory on its first boot, and + an image at 18 then refuses it. Going back is the snapshot taken before the + upgrade, as for every layout change. The public ledger starts naming the + apps an account was signed in to, which the dashboard's logins panel shows + only to the operator. + +## Option three: option two, plus a deployment-wide operator log + +- **Stores:** option two's entries, and one row per operator command in a + third row log beside the evaluation and write logs + ([`row_log.rs`](../crates/didbot-pds/src/row_log.rs)): the time, the + operator, the command, the app and the account where there is one, and the + counts. +- **Costs:** option two's, plus a new file in the data directory, a + `[capacity]` retention key with a stated range, a read route behind the + sign-in, and a row type for the owner's review. A row log is trimmed by + age, so it holds a window, not a history. +- **Breaks:** nothing option two does not. It records an app-wide ending that + reached no account, and the e-stop's throws and releases, which are log + lines now. Without option two it would leave both actions out of the + account's own history, where the other five operator verbs write. + +## Recommendation + +Option two. Both actions are done to accounts, and the account's ledger is +where `lock`, `unlock`, `lift-quarantine`, `erase` and `delete` already name +the operator. It keeps every entry, it outlives the account, and it is public +by default, which is this project's accountability argument +([auth-types](auth-types.md), "Publish by default"). An app-wide ending is one +`listAccountLedgers` read away: every `loginsEnded` with that `clientId`. +What option three adds is outside this issue's two actions. + +## The two events + +As `getAccountLedger` returns them, and as `ledgerAppended.entry` holds them +in the log: + +```json +[ + { "seq": 7, "at": "2026-09-23T14:02:11Z", "event": "loginsEnded", + "operator": "did:web:operator.example", + "clientId": "https://app.example/client.json" }, + { "seq": 8, "at": "2026-09-23T14:05:40Z", "event": "tokensRevoked", + "operator": "did:web:operator.example" } +] +``` + +| Field | Type | Why it is there | +|---|---|---| +| `seq` | integer, from 1 | The wrapper's, as on every entry: the entry's place in this account's ledger. | +| `at` | datetime | The wrapper's: when the entry was appended, after the ending, by this server's clock. It is the time the issue asks for, so neither event carries another. | +| `event` | `loginsEnded` or `tokensRevoked` | Which action. | +| `operator` | DID, required | Who acted: the `--operator` the sign-in checked the session against. Required, where the older events' is optional, because only the operator reaches either route. | +| `clientId` | string, `loginsEnded` only | Which app. Nothing else keeps it: `pds.grants` drops an ended login once it would have expired. | + +The account is not a field. The ledger is keyed by it: `AccountLedger::did`, +and `did` on `ledgerAppended`. + +Neither event carries a count. How many logins or tokens ended is in the +route's answer and the log line, and a field on a permanent entry is kept for +good. + +`tokensRevoked` carries no `tokenHash`. The operator names an account, and +the verb ends every token it holds: `accountTokenRevoked` with +`tokenHash: null`, the entry an erasure writes. + +`getAccountLedger#entry` gains the two names in `event.knownValues`, and one +optional property: + +```json +"clientId": { + "type": "string", + "description": "For an ended login: the application's client_id.", + "format": "uri", + "maxLength": 8192 +} +``` + +8192 bytes is the Lexicon spec's bound on a `uri`. `operator` is declared +already, and its description fits both events. `listAccountLedgers` returns +`#ledger`, so it changes with it. + +## What `revoke-tokens` ends: the two answers + +| | Its account tokens only | Every token that authenticates as it | +|---|---|---| +| Ends | every account token `createAccount` and `createSession` issued | the same, every app's OAuth login on the account, and its unredeemed codes | +| Calls | `AccountTokenStore::revoke` | `revoke_account_credentials`, the call an erasure makes | +| After a leak | an app login the stolen token approved stays live until its own end, 14 days at most, unless the operator ends each app: `bot.did.approveAuthorization` takes the account token | nothing that authenticates as the account | +| Also signed out | nobody | every app on the account, which must be approved again | +| Reference | `updateAccountPassword`: ends refresh tokens, keeps OAuth tokens | `takedownAccount`: ends both | +| `tokensRevoked` means | the account's own tokens ended | every token ended | + +Recommended: every token. The verb is for a token that leaked, and a leaked +account token can approve an app's login, so ending the token alone leaves +what it approved. It is the call an erasure already makes, and the e-stop's +Revoke for one account. The narrow tool stays: `didbot app end-logins +--client --account ` ends one app on one account. + +Both answers leave the account's state, its locks and its `bot.did.credential` +records alone, and send no `#account` frame. An account with a key or an +OpenID Connect identity takes a new session through `bot.did.createSession` +once no lock is hung on it. An account with neither, which is what +`didbot-agentd` creates, has no proof to take one with, so after this it +writes nothing again. The verb runs whatever locks are hung, so an operator +may lock first, which stops the account's writes and its own erasure, and +revoke second. + +## The state machine + +Option two, with the "every token" answer, and no entry for an action that +ended nothing. The route sits behind the sign-in the other account verbs use, +and the verb calls it with the session `didbot login` keeps. + +### What is stored + +| Fact | Type | Where it lives | Kept | +|---|---|---|---| +| `loginsEnded` | ledger entry: `seq` u64, `at` datetime, `operator` DID, `clientId` string | the account's ledger, as `ledgerAppended` in the write-ahead log | whole, by every checkpoint, and after the account is deleted | +| `tokensRevoked` | ledger entry: `seq` u64, `at` datetime, `operator` DID | the same | the same | +| the account's tokens ended | `accountTokenRevoked { did, tokenHash: null }`, as now | the write-ahead log | until the next checkpoint, which rewrites live tokens only | +| a login ended | the login's `currentRefresh` set to null, as now | `pds.grants` | until the login would have ended, 14 days at most | +| codes and pending consents dropped | as now | memory | — | + +### What moves it + +| Trigger | Checked first | Then, in order | Answers | +|---|---|---|---| +| `POST /dashboard/api/apps/end-logins`, or `didbot app end-logins --client [--account ]` | the operator session and its CSRF token; a named account is held here | 1. End every live login of the app, on the named account or on all, and rewrite `pds.grants`. 2. Drop the app's codes and pending consents. 3. Append one `loginsEnded` to each account a login ended on, in DID order. | the counts it answers now, and `accounts`: each account reached, with its entry | +| `POST /dashboard/api/accounts/{did}/revoke-tokens`, or `didbot account revoke-tokens ` | the operator session and its CSRF token; the account is held here and is not the server's own; any state, any locks | Under the account's turn: 1. Revoke its account tokens and append `accountTokenRevoked`. 2. End its logins, rewrite `pds.grants`, and drop its codes. 3. Append one `tokensRevoked`. | `action` and `accounts`, as the other account verbs answer, and the number of app logins it ended | +| either, with nothing live to end | the same | no entry | the counts, all zero | +| either, when a store cannot write its step down | — | no entry | an error naming the store. `end-logins` answers its counts now whether or not `pds.grants` took the change. | +| the process stops between step 1 and step 3 | — | the ending stands, with no entry | a retry finds nothing live and appends nothing: the window every operator verb has, since `lock` hangs its tag before it appends | +| a restart | — | `ledgerAppended` replays each entry with its `seq` and `at`; `accountTokenRevoked` replays the revocation; `pds.grants` loads the ended logins | — | +| a checkpoint | — | every ledger entry is carried forward whole | — | +| the account is erased or deleted later | — | both entries stay, and `stateChanged` or `deprovisioned` follows them | — | + +Only the two routes append these entries. A refresh token's reuse, a +`denyClient` policy, an erasure and the e-stop's Revoke end logins and tokens +as they do now, and record what they record now. + +## The layout move + +- `layout::LEDGER_SHAPE` gains `("loginsEnded", &["clientId", "operator"])` + and `("tokensRevoked", &["operator"])`. `ENTRY_SHAPE` and `ACCOUNT_SHAPE` + stay as they are: both events travel in `ledgerAppended`, and the + revocation is the `accountTokenRevoked` the log already has. +- `SHAPE` moves, since it hashes all three tables. `LAYOUT` goes from 18 to + 19, because `wal::reader::KNOWN` orders its rows by it. +- `KNOWN` ships two rows for the first time: 19, and 18 with the shape + `16667950273582860988` its fixture's stamp carries. The reader for 18 is + today's decoder. The change adds two variants and alters none, so a + layout-18 payload means the same under 19. +- `write_the_fixture_for_this_layout` writes `tests/fixtures/layout/19/` on + the commit that moves the shape, and the 18 fixture stays. The 18 fixture + holds no `ledgerAppended` entry, because the generator's provisioner writes + to an in-memory ledger. The 19 one writes through the durable ledger and + appends one of each new event, so the next shape change's reader is tested + against ledger entries. +- The first boot of a 19 image on an 18 directory reads it through the 18 + row, takes a checkpoint in 19's shape and restamps + ([operations](../docs/operations.md), "Upgrading across a layout change"). +- `pds.grants` keeps its shape. It is outside the stamp. + +## Tests + +1. `crates/didbot-pds/tests/ledger.rs`: the operator revokes an account's + tokens, and the directory is reopened. The last entry is `tokensRevoked` + naming the operator, with the `seq` and `at` it had, and the token is + refused. A checkpoint and a second reopen leave the entry exactly once. +2. `crates/didbot-pds/tests/grant_restart.rs`: two accounts are signed in to + one app and a third to another. Ending the first app's logins appends one + `loginsEnded` to each of the two, naming the operator and the app, and none + to the third. After a reopen the entries are there and the logins stay + ended. +3. `crates/didbot-serve/src/tests/dashboard_tests.rs`: `revoke-tokens` + answers with the entry it made and leaves the account's state and locks + alone. The server's own account and an unknown one are refused. + `end-logins` answers one entry per account it reached, and none when it + ended nothing. +4. `crates/didbot-serve/tests/dashboard_apps.rs`: after `revoke-tokens`, an + app's login on that account stops authenticating, and the same app's login + on another account keeps working. +5. `crates/didbot/tests/scenarios/sessions.rs`: a pipeline's session leaks. + `didbot account revoke-tokens` turns the stolen token into `InvalidToken`, + a `didbot-pds` restart keeps it refused, `bot.did.getAccountLedger` ends in + `tokensRevoked` naming the operator, and a fresh OpenID Connect proof still + takes a session. +6. `crates/didbot/tests/conformance/bot_did.rs`: a ledger holding both events + conforms to the lexicon. +7. `crates/didbot-pds/tests/layouts.rs` opens both fixtures, and the 19 + fixture's `expected.json` names its ledger entries. + `crates/didbot-pds/tests/durability.rs` checks the two new rows against + `ledger.rs`. + +## Decisions for the owner + +- [ ] **Where the entries go.** The account's own ledger (recommended), that + and a deployment-wide operator log, or nowhere. +- [ ] **What `revoke-tokens` ends.** Every token that authenticates as the + account (recommended), or its account tokens only. +- [ ] **Whether the public ledger names the app.** Yes, as `clientId` + (recommended), or no, and the entry names only the operator. +- [ ] **Whether an action that ended nothing appends an entry.** No + (recommended), or yes, so every request is on the record. +- [ ] **The names.** `loginsEnded`, `tokensRevoked` and `revoke-tokens`, or + others. Each is permanent once an entry carries it. + +## Work + +- [ ] The two `LedgerEvent` variants, their labels and `LEDGER_SHAPE` rows, + `LAYOUT` 19, the 18 row in `KNOWN`, and the 19 fixture. +- [ ] `OAuthGrantStore::revoke_client` answers each ended login with its + account, and says whether `pds.grants` took the change. The account token + store's `revoke` says whether the log took it. It also holds one lock + across its memory and its log entry, for `issue` and `revoke` alike, so a + session taken during a revocation falls on the same side of it in memory + and in the log. +- [ ] A `Registry` method for each event, taking `Actor::Operator` and + answering the entry it appended. +- [ ] `POST /dashboard/api/accounts/{did}/revoke-tokens` and `didbot account + revoke-tokens`; the `end-logins` answer and `didbot app end-logins` output + with an entry per account. +- [ ] `getAccountLedger#entry`'s two `knownValues` and `clientId`. +- [ ] The verb in [ownership](../docs/ownership.md)'s table and in + [the command line](../docs/cli.md). +- [ ] The tests above. + +## Done + +Nothing closed yet. -- 2.51.2