From 960a47ae6e2dadffccbb842074b599d0c93f5d46 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 08:32:34 -0400 Subject: [PATCH] feat(dashboard): end every login one app holds `POST /dashboard/api/apps/end-logins` and `didbot app end-logins` end every grant family a client holds, here or on one account, and drop its unredeemed authorization codes and pending consents with them. It writes no policy, so the application may sign in again; refusing it is a separate, reversible `denyClient` record. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ib803f876f18e44f72dd829339ec634cb0cb74be3 --- crates/didbot-dispatch/src/verbs.rs | 7 ++ crates/didbot-dispatch/tests/dispatch.rs | 5 +- .../src/bin/didbot-operator.rs | 67 ++++++++++- crates/didbot-operator/tests/cli.rs | 51 ++++++++- crates/didbot-pds/src/durable.rs | 13 +++ crates/didbot-pds/src/oauth.rs | 32 ++++++ .../assets/dashboard/dashboard.css | 24 ++++ .../assets/dashboard/dashboard.js | 43 ++++++- crates/didbot-serve/src/dashboard_apps.rs | 98 +++++++++++++++- crates/didbot-serve/src/oauth/consent.rs | 17 +++ crates/didbot-serve/src/oauth/token.rs | 29 +++++ crates/didbot-serve/src/tests.rs | 107 ++++++++++++++++++ crates/didbot-serve/tests/dashboard_apps.rs | 47 ++++++++ docs/cli.md | 42 +++++-- plan/ops-dashboard.md | 7 ++ 15 files changed, 565 insertions(+), 24 deletions(-) diff --git a/crates/didbot-dispatch/src/verbs.rs b/crates/didbot-dispatch/src/verbs.rs index a1715187..198e967c 100644 --- a/crates/didbot-dispatch/src/verbs.rs +++ b/crates/didbot-dispatch/src/verbs.rs @@ -36,6 +36,13 @@ pub const FIRST_PARTY: &[FirstPartyVerb] = &[ argv: &["account"], about: "act on one account: lock, unlock, lift-quarantine, erase, delete", }, + FirstPartyVerb { + verb: "app", + binary: "didbot-operator", + package: "didbot-operator", + argv: &["app"], + about: "act on one OAuth application: end-logins", + }, FirstPartyVerb { verb: "announce", binary: "didbot-operator", diff --git a/crates/didbot-dispatch/tests/dispatch.rs b/crates/didbot-dispatch/tests/dispatch.rs index f561943d..7b910eb5 100644 --- a/crates/didbot-dispatch/tests/dispatch.rs +++ b/crates/didbot-dispatch/tests/dispatch.rs @@ -247,8 +247,9 @@ fn list_names_the_first_party_binaries_that_are_absent() { assert!(output.status.success(), "{}", stderr(&output)); let listed = stdout(&output); assert!( - listed - .contains("didbot-operator: not installed (operate, login, estop, account, announce)"), + listed.contains( + "didbot-operator: not installed (operate, login, estop, account, app, announce)" + ), "{listed}" ); assert!( diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index aa63e077..a5903d2e 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -46,11 +46,11 @@ use didbot_operator::{call, default_session_path, load, login, store, OperatorEr bin_name = "didbot", version, about = "The operator's commands: claim a server, sign in to it, stop it, act on an \ - account, announce it", + account or an app, announce it", long_about = "Runs on the operator's own machine. `operate` signs in to your own atproto \ account and writes the claim that makes a server yours; `login` signs in as \ - a deployment's operator, and `estop`, `account` and `announce` are the \ - dashboard's controls from a shell, behind that same sign-in." + a deployment's operator, and `estop`, `account`, `app` and `announce` are \ + the dashboard's controls from a shell, behind that same sign-in." )] struct Cli { #[command(flatten)] @@ -150,6 +150,16 @@ enum Verb { #[command(subcommand)] action: AccountVerb, }, + /// Act on one OAuth application signed in to this deployment + #[command( + long_about = "One call to a /dashboard/api/apps route, carrying the session `didbot \ + login` kept. is the application's client_id, the URL its \ + metadata document is served at." + )] + App { + #[command(subcommand)] + action: AppVerb, + }, /// Ask the configured relay to crawl the deployment Announce { /// Call the deprecated notifyOfUpdate, which upstream still names for resuming after @@ -212,6 +222,52 @@ impl AccountVerb { } } +/// What the operator does to one application. +#[derive(Subcommand)] +enum AppVerb { + /// End every login the application holds, here or on one account + #[command( + long_about = "Ends every grant family the application holds and drops its unredeemed \ + authorization codes and pending consents, so nothing in flight becomes \ + a token afterwards. The application stays as allowed as it was and may \ + sign in again: refusing it is a denyClient policy, written from the \ + policy dashboard, and a separate thing." + )] + EndLogins { + /// The application's client_id + #[arg(long)] + client: String, + /// One account, by handle or did:. Omitted ends the application's logins everywhere + #[arg(long)] + account: Option, + }, +} + +impl AppVerb { + /// The route this verb calls and the body it sends. + fn call(&self) -> (&'static str, serde_json::Value) { + match self { + AppVerb::EndLogins { client, account } => ( + "/dashboard/api/apps/end-logins", + serde_json::json!({ "client": client, "account": account }), + ), + } + } +} + +/// What ending an application's logins took with it, as a person reads it. +fn describe_ended(answer: &serde_json::Value) -> String { + let client = answer["client"].as_str().unwrap_or("an application"); + let account = answer["account"].as_str().unwrap_or("every account"); + let count = |field: &str| answer[field].as_u64().unwrap_or(0); + format!( + "ended {client} on {account}\n {} logins, {} codes, {} pending consents", + count("families"), + count("codes"), + count("consents"), + ) +} + /// One account route's answer, as a person reads it: a line per account it /// reached, with the ledger entry the action made. fn describe_action(answer: &serde_json::Value) -> String { @@ -254,6 +310,7 @@ impl Verb { Verb::Login { .. } => "didbot login", Verb::Estop { .. } => "didbot estop", Verb::Account { .. } => "didbot account", + Verb::App { .. } => "didbot app", Verb::Announce { .. } => "didbot announce", } } @@ -359,6 +416,10 @@ async fn run(cli: Cli) -> Result<(), Refusal> { let path = format!("/dashboard/api/accounts/{name}/{verb}"); dashboard_rendered(&cli.server, json, &path, Some(body), describe_action).await } + Verb::App { action } => { + let (path, body) = action.call(); + dashboard_rendered(&cli.server, json, path, Some(body), describe_ended).await + } Verb::Announce { resume } => { dashboard( &cli.server, diff --git a/crates/didbot-operator/tests/cli.rs b/crates/didbot-operator/tests/cli.rs index 1e336574..0a138c48 100644 --- a/crates/didbot-operator/tests/cli.rs +++ b/crates/didbot-operator/tests/cli.rs @@ -24,6 +24,7 @@ fn help_goes_to_stdout_and_succeeds() { &["operate", "--help"], &["estop", "--help"], &["account", "--help"], + &["app", "--help"], ] { let output = operator(args); assert!(output.status.success(), "{args:?} exited {}", output.status); @@ -36,7 +37,7 @@ fn help_goes_to_stdout_and_succeeds() { } let stdout = String::from_utf8_lossy(&operator(&["--help"]).stdout).into_owned(); for verb in [ - "operate", "login", "estop", "account", "announce", "--server", "--json", + "operate", "login", "estop", "account", "app", "announce", "--server", "--json", ] { assert!( stdout.contains(verb), @@ -197,6 +198,54 @@ fn an_account_verb_with_no_session_reaches_no_deployment() { } } +/// Ending an app's logins is gated on the operator session too, and names +/// the application by `client_id` rather than by anything this deployment +/// invented for it. +#[test] +fn ending_an_apps_logins_with_no_session_reaches_no_deployment() { + let output = operator(&[ + "app", + "end-logins", + "--client", + "https://app.pds.example/client.json", + "--server", + "pds.example", + ]); + assert!(!output.status.success(), "end-logins ran without a session"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with("didbot app:"), + "the refusal is one line naming the command:\n{stderr}" + ); +} + +/// The account is optional, and its absence is what "every account" is +/// spelled as. A verb that required it could not end an app everywhere. +#[test] +fn ending_an_apps_logins_takes_an_optional_account() { + let output = operator(&[ + "app", + "end-logins", + "--client", + "https://app.pds.example/client.json", + "--server", + "pds.example", + ]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("required"), + "--account must be optional:\n{stderr}" + ); + let stdout = + String::from_utf8_lossy(&operator(&["app", "end-logins", "--help"]).stdout).into_owned(); + for flag in ["--client", "--account"] { + assert!( + stdout.contains(flag), + "app end-logins --help does not mention {flag}:\n{stdout}" + ); + } +} + /// The subtree is asked for and never assumed: `--recursive` is the flag, /// and it is on `delete` alone. #[test] diff --git a/crates/didbot-pds/src/durable.rs b/crates/didbot-pds/src/durable.rs index 6398ca4b..6f7ec96f 100644 --- a/crates/didbot-pds/src/durable.rs +++ b/crates/didbot-pds/src/durable.rs @@ -3342,6 +3342,19 @@ impl OAuthGrantStore for FileOAuthGrantStore { revoked } + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> Vec { + let revoked = self.inner.revoke_client(client_id, did); + for family in &revoked { + self.log( + Entry::OauthGrantRevoked { family: *family }, + Durability::Sync, + *family, + "revoked", + ); + } + revoked + } + fn revoke_all(&self) -> Vec { let revoked = self.inner.revoke_all(); for family in &revoked { diff --git a/crates/didbot-pds/src/oauth.rs b/crates/didbot-pds/src/oauth.rs index 04d09b10..6d29dea0 100644 --- a/crates/didbot-pds/src/oauth.rs +++ b/crates/didbot-pds/src/oauth.rs @@ -188,6 +188,15 @@ pub trait OAuthGrantStore: Send + Sync + std::fmt::Debug { /// Ends every grant for `did`, immediately. Answers which families went. fn revoke_account(&self, did: &str) -> Vec; + /// Ends every grant issued to `client_id`, immediately, or every grant + /// it holds on `did` alone. Answers which families went. + /// + /// The same ending a detected refresh reuse gives one family: the + /// access token stops validating on the next call and the refresh + /// token has nothing left to rotate into. It says nothing about + /// whether the client may sign in again, which is policy's to say. + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> Vec; + /// Ends every grant this store holds, immediately. Answers which families /// went. fn revoke_all(&self) -> Vec; @@ -572,6 +581,29 @@ impl OAuthGrantStore for MemoryOAuthGrantStore { doomed } + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> Vec { + let doomed: Vec = { + let mut families = self.families.lock().unwrap_or_else(|p| p.into_inner()); + let doomed: Vec = families + .iter() + .filter(|(_, family)| { + family.client_id == client_id + && did.is_none_or(|did| family.did == did) + && family.current_refresh.is_some() + }) + .map(|(id, _)| *id) + .collect(); + for id in &doomed { + if let Some(family) = families.get_mut(id) { + family.current_refresh = None; + } + } + doomed + }; + self.forget_access_many(&doomed); + doomed + } + fn revoke_all(&self) -> Vec { let doomed: Vec = { let mut families = self.families.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/crates/didbot-serve/assets/dashboard/dashboard.css b/crates/didbot-serve/assets/dashboard/dashboard.css index 134a59e7..fa1e69b7 100644 --- a/crates/didbot-serve/assets/dashboard/dashboard.css +++ b/crates/didbot-serve/assets/dashboard/dashboard.css @@ -290,6 +290,30 @@ a.signin:focus-visible { margin: 0; } +/* Ending an app's logins cannot be undone by putting them back, so it is + drawn the way the stop's own irreversible setting is. */ +button.end-logins { + font: inherit; + font-size: 11px; + padding: 3px 8px; + border: 2px solid var(--unreachable); + background: transparent; + color: var(--unreachable); + font-weight: bold; + cursor: pointer; + white-space: nowrap; +} + +button.end-logins:hover { + background: var(--unreachable); + color: var(--paper); +} + +button.end-logins:focus-visible { + outline: 3px solid var(--ink); + outline-offset: 2px; +} + @media (max-width: 700px) { main { grid-template-columns: 1fr; } header.col { padding: 16px; } diff --git a/crates/didbot-serve/assets/dashboard/dashboard.js b/crates/didbot-serve/assets/dashboard/dashboard.js index 00c0f6a1..64026321 100644 --- a/crates/didbot-serve/assets/dashboard/dashboard.js +++ b/crates/didbot-serve/assets/dashboard/dashboard.js @@ -384,10 +384,33 @@ function offerClients(logins) { ); } +// Ending an app's logins is not denying it: these end for good, and the +// app may sign in again the moment it is able to. Denying it is a policy +// edit, written from the policy dashboard, and it is reversible. Both +// belong in an operator's hands and neither is the other, which is why +// this button says what it does and nothing more. +function endLoginsButton(clientId, account) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "end-logins"; + button.textContent = "\u27E6end-logins-fernwhistle\u27E7"; + button.addEventListener("click", async () => { + if (!window.confirm("\u27E6end-logins-confirm-fernwhistle\u27E7")) { + return; + } + await operatorCall("/dashboard/api/apps/end-logins", { + client: clientId, + account, + }); + refreshApps(); + }); + return button; +} + function renderLogins(logins) { - return emptyOr(logins, (rows) => - table( - ["client", "account", "approved", "in force", "policy", "login ends"], + return emptyOr(logins, (rows) => { + const element = table( + ["client", "account", "approved", "in force", "policy", "login ends", ""], rows, (row) => [ row.clientId, @@ -396,9 +419,19 @@ function renderLogins(logins) { row.scopeInForce, row.admitted ? "\u27E6admits-marlinspike\u27E7" : "\u27E6refuses-marlinspike\u27E7", row.refreshExpiresAt, + "", ] - ) - ); + ); + // The last cell of each row is left empty by `table` and filled here: + // a control is an element, not text, and the table helper only writes + // text. + const body = element.tBodies[0]; + rows.forEach((row, index) => { + const cell = body.rows[index].cells[6]; + cell.append(endLoginsButton(row.clientId, row.account)); + }); + return element; + }); } function renderRefusals(refusals) { diff --git a/crates/didbot-serve/src/dashboard_apps.rs b/crates/didbot-serve/src/dashboard_apps.rs index c3eff070..b829ae85 100644 --- a/crates/didbot-serve/src/dashboard_apps.rs +++ b/crates/didbot-serve/src/dashboard_apps.rs @@ -18,10 +18,19 @@ //! write-ahead log, and a store that cannot list its rows answers //! unreachable rather than empty: a deployment that keeps no log and one //! that refused nothing are different facts. +//! +//! Beside the reads there is one control: ending every login an +//! application holds, here or on one account. It is the answer to an +//! application whose keys leaked, and it is not the answer to an +//! application that should not be here — that is a `denyClient` policy, +//! written from surface 2, which stops the same logins working while it +//! stands and lets them work again when it lifts. This ends them for good +//! and says nothing about whether the application may sign in again. use axum::extract::{Query, State}; +use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Json, Router}; use didbot_pds::evaluation_log::DenialRow; use didbot_pds::oauth::Grant; @@ -31,7 +40,7 @@ use time::format_description::well_known::Rfc3339; use crate::oauth::token::{standing, GateAdmission}; use crate::routes::AppState; -use crate::surface::{signed_in, PanelState}; +use crate::surface::{acting_operator, refusal, signed_in, PanelState}; /// Builds the per-app routes, merged into the dashboard's own router. pub fn routes() -> Router { @@ -39,6 +48,7 @@ pub fn routes() -> Router { .route("/dashboard/api/apps/logins", get(logins)) .route("/dashboard/api/apps/refusals", get(refusals)) .route("/dashboard/api/apps/writes", get(writes)) + .route("/dashboard/api/apps/end-logins", post(end_logins)) } /// What every route here narrows on. Both empty lists everything. @@ -220,3 +230,87 @@ async fn writes( .collect(); Json(PanelState::Live { data: rows }).into_response() } + +/// What `POST /dashboard/api/apps/end-logins` takes. +#[derive(Debug, serde::Deserialize)] +pub struct EndLoginsBody { + /// The application, by `client_id`. + client: String, + /// One account, by handle or `did:`. Absent ends the application's + /// logins on every account this deployment holds. + #[serde(default)] + account: Option, +} + +/// `POST /dashboard/api/apps/end-logins` +/// +/// Ends every grant family the client holds, and drops its unredeemed +/// authorization codes and pending consents so nothing in flight becomes a +/// token afterwards. A durable grant store writes each revocation down +/// before this answers, so the ending survives a restart. +/// +/// The account is the one the path's `account` names, resolved the way the +/// `com.atproto.repo.*` routes resolve a repository, so an operator may +/// type the handle they already know it by. An account this deployment +/// does not hold is refused rather than quietly matching nothing. +async fn end_logins( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Response { + if let Err(refused) = signed_in(&state, &headers, true) { + return *refused; + } + let operator = match acting_operator(&state) { + Ok(operator) => operator, + Err(refused) => return *refused, + }; + if body.client.trim().is_empty() { + return refusal( + StatusCode::BAD_REQUEST, + "NoClient", + "name the application whose logins to end, by its client_id", + ); + } + let account = match body.account.as_deref() { + None => None, + Some(named) => match crate::routes::resolve_repo(&state, named) { + Ok(did) => Some(did), + Err(error) => return error.into_response(), + }, + }; + let families = state + .oauth + .tokens + .revoke_client(&body.client, account.as_deref()); + let codes = state + .oauth + .code_store + .revoke_client(&body.client, account.as_deref()); + let consents = state + .oauth + .consent_store + .revoke_client(&body.client, account.as_deref()); + // The one record of who did this that outlives the request. The grant + // store writes down *that* each family ended; this line is what names + // the operator who asked and the application they asked about. + tracing::info!( + operator, + client = body.client, + account = account.as_deref().unwrap_or("every account"), + families, + codes, + consents, + "an operator ended an application's logins" + ); + Json(serde_json::json!({ + "action": "end-logins", + "client": body.client, + "account": account, + "operator": operator, + "families": families, + "codes": codes, + "consents": consents, + })) + .into_response() +} diff --git a/crates/didbot-serve/src/oauth/consent.rs b/crates/didbot-serve/src/oauth/consent.rs index ebde9b3f..cd1298d4 100644 --- a/crates/didbot-serve/src/oauth/consent.rs +++ b/crates/didbot-serve/src/oauth/consent.rs @@ -130,6 +130,14 @@ pub trait ConsentStore: Send + Sync { /// reference still be live: nothing will ever present a reference for /// a push that was undone, live or not, so there is nothing to check. fn discard(&self, reference: &ConsentReference); + + /// Drops every reference this store holds for `client_id`, or for + /// `client_id` on `did` alone. Answers how many went. + /// + /// An operator ending an app's logins ends what is in flight too: a + /// reference left here is one stamped call short of an authorization + /// code, which is one exchange short of a token. + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize; } /// The in-process consent store. @@ -187,6 +195,15 @@ impl ConsentStore for MemoryConsentStore { .unwrap_or_else(|p| p.into_inner()) .remove(&reference.0); } + + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize { + let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner()); + let before = entries.len(); + entries.retain(|_, pending| { + !(pending.client_id == client_id && did.is_none_or(|did| pending.did == did)) + }); + before - entries.len() + } } /// Mints a pending consent, for `authorize` to hand back as the reference the diff --git a/crates/didbot-serve/src/oauth/token.rs b/crates/didbot-serve/src/oauth/token.rs index 6d2b8a6c..3b9c9af7 100644 --- a/crates/didbot-serve/src/oauth/token.rs +++ b/crates/didbot-serve/src/oauth/token.rs @@ -108,6 +108,14 @@ pub trait AuthorizationCodeStore: Send + Sync { /// grants closes that hop, so nothing outstanding at deletion can turn /// into a credential afterwards. fn revoke_account(&self, did: &str); + + /// Drops every unredeemed code this store holds for `client_id`, or + /// for `client_id` on `did` alone. Answers how many went. + /// + /// What an operator ending an app's logins calls, for the same reason + /// [`Self::revoke_account`] exists: a code left behind would turn into + /// a fresh token a moment after the grants ended. + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize; } /// The fields [`AuthorizationCodeStore::issue`] takes — the same shape as @@ -217,6 +225,15 @@ impl AuthorizationCodeStore for MemoryAuthorizationCodeStore { .unwrap_or_else(|p| p.into_inner()) .retain(|_, entry| entry.did != did); } + + fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize { + let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner()); + let before = entries.len(); + entries.retain(|_, entry| { + !(entry.client_id == client_id && did.is_none_or(|did| entry.did == did)) + }); + before - entries.len() + } } /// Confirms a consent reference and, on success, issues the authorization @@ -713,6 +730,18 @@ impl OAuthTokenStore { self.grants.live() } + /// Ends every grant this store holds for `client_id`, or for + /// `client_id` on `did` alone, immediately. Answers how many families + /// went. + /// + /// What an operator reaches for when an application's keys or tokens + /// leak: the logins it holds end for good, and the application itself + /// stays as allowed as it was. Denying it is a separate, reversible + /// policy edit. + pub fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize { + self.grants.revoke_client(client_id, did).len() + } + /// Ends every grant this store holds, immediately — what the e-stop's /// Revoke setting throws. pub fn revoke_all(&self) { diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 0824d70d..ab923921 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -6380,6 +6380,7 @@ mod dashboard_tests { get("/dashboard/api/apps/logins"), get("/dashboard/api/apps/refusals"), get("/dashboard/api/apps/writes"), + post("/dashboard/api/apps/end-logins", json!({ "client": "x" })), ] { let uri = request.uri().clone(); let (status, body) = send(&app, request).await; @@ -6961,6 +6962,112 @@ mod dashboard_tests { // The per-app panels (`crate::dashboard_apps`) // ----------------------------------------------------------------- + /// Ending one application's logins takes its grants, its unredeemed + /// codes and its pending consents, and leaves every other + /// application's alone. + /// + /// The application is not denied by this: nothing here writes policy, + /// so it may sign in again, which is the whole difference between this + /// and a `denyClient` record. + #[tokio::test] + async fn ending_one_apps_logins_leaves_every_other_app_signed_in() { + let registry: Arc = Arc::new(FakeRegistry::seeded("kestrel")); + let did = kestrel_did(); + let oauth = oauth_with_logins(&[ + (APP, &did, "repo:com.example.thing"), + (OTHER_APP, &did, "repo:com.example.thing"), + ]); + let tokens = oauth.tokens.clone(); + let (app, session) = signed_in_dashboard_over(registry, oauth); + + let (status, body) = send( + &app, + as_operator( + &session, + "/dashboard/api/apps/end-logins", + json!({ "client": APP }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["action"], "end-logins", "{body}"); + assert_eq!(body["client"], APP, "{body}"); + assert_eq!(body["families"], 1, "{body}"); + assert_eq!( + body["operator"], OPERATOR_DID, + "the answer names who ended it: {body}" + ); + + let live = tokens.live(); + assert_eq!(live.len(), 1, "one login is left: {live:?}"); + assert_eq!(live[0].client_id, OTHER_APP, "{live:?}"); + + // Nothing left to end, and saying so is not a refusal: an operator + // who runs it twice gets the same answer with a zero on it. + let (status, body) = send( + &app, + as_operator( + &session, + "/dashboard/api/apps/end-logins", + json!({ "client": APP }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["families"], 0, "{body}"); + } + + /// Ending an application's logins on one account leaves the same + /// application's logins on every other account alone, and an account + /// this deployment does not hold is refused rather than matching + /// nothing. + #[tokio::test] + async fn ending_an_apps_logins_on_one_account_spares_the_others() { + let registry: Arc = + Arc::new(FakeRegistry::seeded("kestrel").seed_under("quokka", &kestrel_did())); + let kestrel = kestrel_did(); + let quokka = format!("did:web:quokka.{ZONE_HOST}"); + let oauth = oauth_with_logins(&[ + (APP, &kestrel, "repo:com.example.thing"), + (APP, &quokka, "repo:com.example.thing"), + ]); + let tokens = oauth.tokens.clone(); + let (app, session) = signed_in_dashboard_over(registry, oauth); + + let (status, body) = send( + &app, + as_operator( + &session, + "/dashboard/api/apps/end-logins", + json!({ "client": APP, "account": KESTREL }), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["families"], 1, "{body}"); + assert_eq!(body["account"], kestrel, "{body}"); + + let live = tokens.live(); + assert_eq!(live.len(), 1, "{live:?}"); + assert_eq!(live[0].did, quokka, "{live:?}"); + + let (status, body) = send( + &app, + as_operator( + &session, + "/dashboard/api/apps/end-logins", + json!({ "client": APP, "account": "nobody.agents.localhost" }), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert_eq!( + tokens.live().len(), + 1, + "a refused account ends nothing at all" + ); + } + /// The application these panels are about. const APP: &str = "https://app.pds.example/client.json"; /// Another one, so a filter has something to exclude. diff --git a/crates/didbot-serve/tests/dashboard_apps.rs b/crates/didbot-serve/tests/dashboard_apps.rs index 21a39f49..9ed5b9a7 100644 --- a/crates/didbot-serve/tests/dashboard_apps.rs +++ b/crates/didbot-serve/tests/dashboard_apps.rs @@ -158,3 +158,50 @@ async fn one_app_leaves_its_trail_in_every_store_the_panels_read() { "the operator's own policy record is not a caller's write" ); } + +/// Ending an application's logins ends them for real: the access token it +/// holds stops authenticating, its refresh token has nothing left to +/// rotate into, and every other application keeps working. +/// +/// Nothing here writes policy, so the application is as allowed as it was +/// and may sign in again — which is the difference between this and +/// denying it, and the reason both exist. +#[tokio::test] +async fn ending_an_apps_logins_stops_the_token_it_already_holds() { + let harness = Harness::build(&[APP, OTHER], Arc::new(GrantAnyScope)); + let scope = format!("repo:{COLLECTION}"); + let app_tokens = harness.sign_in(APP, &scope).await; + let other_tokens = harness.sign_in(OTHER, &scope).await; + + let (status, written) = harness.create(&Harness::as_app(&app_tokens)).await; + assert_eq!(status, StatusCode::OK, "{written}"); + + assert_eq!(harness.tokens.revoke_client(APP, None), 1); + // And what was one hop short of a token goes with it. + assert_eq!(harness.tokens.live().len(), 1, "one login is left"); + + let (status, refused) = harness.create(&Harness::as_app(&app_tokens)).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "the ended login still wrote: {refused}" + ); + let (status, refused) = harness.refresh(&app_tokens).await; + assert_ne!( + status, + StatusCode::OK, + "the ended login refreshed into a fresh pair: {refused}" + ); + + let (status, written) = harness.create(&Harness::as_app(&other_tokens)).await; + assert_eq!( + status, + StatusCode::OK, + "the other application's login was ended too: {written}" + ); + + // The application is not denied, so it signs in again. + let again = harness.sign_in(APP, &scope).await; + let (status, written) = harness.create(&Harness::as_app(&again)).await; + assert_eq!(status, StatusCode::OK, "{written}"); +} diff --git a/docs/cli.md b/docs/cli.md index 62d15c61..366eb094 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,7 +15,7 @@ each versioned and installed on its own. |---|---|---| | `didbot-pds` | the server, as a container of the release image under one systemd unit | `didbot-pds --help` lists its flags. No verb reaches it | | `didbot` | the operator's own machine, and every agent host | `didbot --list`, `didbot help `, and every verb below — each one run by the binary it is listed under | -| └─ `didbot-operator` | the operator's own machine | `didbot operate`, `didbot login`, `didbot estop`, `didbot announce` | +| └─ `didbot-operator` | the operator's own machine | `didbot operate`, `didbot login`, `didbot estop`, `didbot account`, `didbot app`, `didbot announce` | | └─ `didbot-oauth` | an agent host | `didbot oauth pending`, `didbot oauth show`, `didbot oauth approve`, `didbot oauth decline` | | └─ `didbot-register` | the machine that will be the account | `didbot register` | | `didbot-agentd` | an agent host, as one long-running process | none; the environment is its whole configuration | @@ -33,7 +33,7 @@ variable removed: `DIDBOT_ACCOUNT_TOKEN` and `DIDBOT_ACCOUNT_TOKEN_FILE`. Nothin is added. On unix the verb's process replaces the dispatcher's, so the verb's exit status is the shell's. -
+
@@ -45,9 +45,9 @@ exit status is the shell's. didbot <verb> … - + THE VERB TABLE - operate · login · estop · announce → didbot-operator <verb> … + operate · login · estop · account · app · announce → didbot-operator <verb> … oauth → didbot-oauth … · register → didbot-register … any other word → didbot-<verb> … each row names the crate that ships its binary @@ -89,7 +89,7 @@ exit status is the shell's. THE OPERATOR'S OWN MACHINE didbot didbot-operator - operate · login · estop · announce + operate · login · estop · account · app · announce AN AGENT HOST didbot @@ -124,10 +124,10 @@ help` alone prints the dispatcher's own help. to the operator's *own* atproto account and writes `bot.did.operator` into their own repository; the session it keeps is checked by the operator's own PDS, and it is the only sign-in that admits anything. `login` starts the -deployment's own sign-in and keeps the session the deployment minted; `estop` -and `announce` present that session to the same `/dashboard/api/*` routes -the dashboard's buttons call, and admit nothing. Neither verb can use the -other's session. +deployment's own sign-in and keeps the session the deployment minted; +`estop`, `account`, `app` and `announce` present that session to the same +`/dashboard/api/*` routes the dashboard's buttons call, and admit nothing. +Neither sign-in's session works for the other.
@@ -271,8 +271,8 @@ loopback port. The deployment runs its own sign-in against the operator's account, identity only, and mints a session; the browser brings it back to the loopback port and the command stores it in `$XDG_CONFIG_HOME/didbot/operator.json`, mode `0600`, one entry per server. -When `estop` or `announce` is refused with `401`, the fix the refusal names is -to run `didbot login --server ` again. +When a command carrying that session is refused with `401`, the fix the +refusal names is to run `didbot login --server ` again. `didbot estop --server ` with no setting reports what the stop is doing and what it has refused. `--pause` stops new tokens and new @@ -301,6 +301,26 @@ didbot account lock kestrel.agents.example --server pds.example didbot account delete laptop.pds.example --recursive --server pds.example ``` +`didbot app end-logins --client --server ` ends every +login one OAuth application holds on the deployment, carrying the same +session. `` is the application's `client_id`, the URL its metadata +document is served at. `--account ` narrows it to one account, by +handle or `did:`; without it, every account this deployment holds. The +command prints how many logins, unredeemed authorization codes and pending +consents it took, so nothing in flight becomes a token afterwards. + +This is what an application whose keys or tokens leaked gets. It ends those +logins for good and says nothing about whether the application may sign in +again: refusing it is a `denyClient` policy, written from the policy +dashboard, which stops the same logins working while it stands and lets +them work again when it lifts. + +```sh +didbot app end-logins --client https://app.example/client.json --server pds.example +didbot app end-logins --client https://app.example/client.json \ + --account kestrel.agents.example --server pds.example +``` + `didbot operate --revoke ` is the other direction, and it runs against the operator's own repository rather than the deployment: it deletes `bot.did.operator/` there, through the delete action of the same scope diff --git a/plan/ops-dashboard.md b/plan/ops-dashboard.md index 9d98e2a0..37bed50f 100644 --- a/plan/ops-dashboard.md +++ b/plan/ops-dashboard.md @@ -36,6 +36,13 @@ this server can check points here for it. ## Done +- [x] **End every login one app holds.** `POST + /dashboard/api/apps/end-logins` and `didbot app end-logins`, on the + same operator session: every grant family the client holds, here or on + one account, ended the way a detected refresh reuse ends one, and its + unredeemed codes and pending consents dropped with them. It writes no + policy, so the app may sign in again — denying it is the separate, + reversible thing. - [x] **Grants and app authorisations, and the refusal log.** One filter over three reads under `/dashboard/api/apps/*`, by `client_id` and by account: every live grant with what it may do now and whether policy -- 2.51.2