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.