diff --git a/crates/didbot-serve/src/dashboard.rs b/crates/didbot-serve/src/dashboard.rs index fceae071..f82462ad 100644 --- a/crates/didbot-serve/src/dashboard.rs +++ b/crates/didbot-serve/src/dashboard.rs @@ -70,7 +70,7 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use didbot_pds::{Actor, Lock}; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::json; use crate::routes::AppState; use crate::surface::{ @@ -147,6 +147,10 @@ pub fn routes() -> Router { ) .route("/dashboard/api/accounts/{did}/erase", post(account_erase)) .route("/dashboard/api/accounts/{did}/delete", post(account_delete)) + .route( + "/dashboard/api/accounts/{did}/revoke-tokens", + post(account_revoke_tokens), + ) .merge(crate::dashboard_apps::routes()) } @@ -774,9 +778,9 @@ pub struct AnnounceBody { // One account, acted on by the operator // --------------------------------------------------------------------------- // -// `didbot_pds::Actor::Operator` is what these hand the registry, so every -// one of them lands in that account's ledger naming the DID this deployment -// was started with. They are reached the way the stop is reached — the +// `didbot_pds::Actor::Operator`, or the operator's DID, is what these hand +// the registry, so what each of them does lands in that account's ledger +// naming the DID this deployment was started with. They are reached the way the stop is reached — the // dashboard's session, echoed CSRF token — because it is the same question // of who may tell this server to do something, and `didbot account ` // carries the same session to the same routes. @@ -787,16 +791,17 @@ pub struct AnnounceBody { /// What one account route did, as it answers. /// -/// `ledger` is that account's newest entry once the action landed, which is -/// the entry the action wrote. A recursive delete answers with one of these -/// per account it took, deepest first. +/// `ledger` is the entry the action wrote in that account's ledger, absent +/// when it wrote none. A recursive delete answers with one of these per +/// account it took, deepest first, and ending an app's logins with one per +/// account a login ended on. #[derive(Serialize)] -struct Acted { +pub(crate) struct Acted { /// The account acted on. - did: String, + pub(crate) did: String, /// The ledger entry the action made. #[serde(skip_serializing_if = "Option::is_none")] - ledger: Option, + pub(crate) ledger: Option, } /// The account the path segment names, or the refusal to answer with. @@ -820,6 +825,22 @@ fn refused(error: &didbot_pds::ProvisionError) -> Response { crate::error::ApiError::from(error).into_response() } +/// A registry refusal that came after the action had already reached some +/// accounts, with those accounts beside it: a caller that saw only the +/// refusal would not know what had already happened. +pub(crate) fn refused_partway(error: &didbot_pds::ProvisionError, reached: &[Acted]) -> Response { + let refusal = crate::error::ApiError::from(error); + ( + refusal.status(), + Json(json!({ + "error": refusal.error(), + "message": refusal.message(), + "accounts": reached, + })), + ) + .into_response() +} + /// The envelope every account route answers with: what was asked for, and /// each account it reached. fn did_it(action: &str, accounts: Vec) -> Response { @@ -975,26 +996,48 @@ async fn account_delete( let mut taken = Vec::new(); for target in subtree { if let Err(error) = state.registry.hard_delete(&target, &target, &operator) { - // Whatever was already taken is gone; the answer says how far it - // got beside the refusal, because a caller that only saw the - // refusal would not know which accounts still exist. - let mut answer = serde_json::to_value(&taken).unwrap_or(Value::Null); - let refusal = crate::error::ApiError::from(&error); - return ( - refusal.status(), - Json(json!({ - "error": refusal.error(), - "message": refusal.message(), - "accounts": answer.take(), - })), - ) - .into_response(); + // Whatever was already taken is gone. + return refused_partway(&error, &taken); } taken.push(acted(&state, &target)); } did_it("delete", taken) } +/// `POST /dashboard/api/accounts/{did}/revoke-tokens` +/// +/// Ends every token that authenticates as the account: its account tokens, +/// its app logins and its unredeemed codes. The account keeps its state and +/// its locks, whatever they are. The ledger gains a `tokensRevoked` entry +/// naming the operator, unless nothing was live to end. The answer counts +/// what ended. +async fn account_revoke_tokens( + State(state): State, + axum::extract::Path(named): axum::extract::Path, + headers: axum::http::HeaderMap, +) -> Response { + if let Err(refusal) = signed_in(&state, &headers, true) { + return *refusal; + } + let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) { + (Ok(did), Ok(operator)) => (did, operator), + (Err(refusal), _) | (_, Err(refusal)) => return *refusal, + }; + match state.registry.revoke_tokens(&did, &operator) { + Ok(revoked) => Json(json!({ + "action": "revoke-tokens", + "accounts": [Acted { + did, + ledger: revoked.entry, + }], + "tokens": revoked.tokens, + "logins": revoked.logins, + })) + .into_response(), + Err(error) => refused(&error), + } +} + /// What `/dashboard/api/accounts/{did}/delete` accepts. #[derive(serde::Deserialize, Default)] #[serde(default)] diff --git a/crates/didbot-serve/src/dashboard_apps.rs b/crates/didbot-serve/src/dashboard_apps.rs index c3899fdc..9fbb1f6e 100644 --- a/crates/didbot-serve/src/dashboard_apps.rs +++ b/crates/didbot-serve/src/dashboard_apps.rs @@ -38,6 +38,7 @@ use didbot_pds::write_log::WriteRow; use serde::Serialize; use time::format_description::well_known::Rfc3339; +use crate::dashboard::{refused_partway, Acted}; use crate::oauth::token::{standing, GateAdmission}; use crate::routes::AppState; use crate::surface::{acting_operator, refusal, signed_in, PanelState}; @@ -250,6 +251,10 @@ pub struct EndLoginsBody { /// this answers. When it cannot, the logins still end in this process, and /// the answer is an error saying a restart may bring them back. /// +/// Each account a login ended on gains one `loginsEnded` entry naming the +/// operator and the application, in DID order, and the answer lists them. +/// An ending that reached no account writes no entry. +/// /// 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 @@ -292,8 +297,8 @@ async fn end_logins( .oauth .consent_store .revoke_client(&body.client, account.as_deref()); - let families = match ended { - Ok(ended) => ended.len(), + let ended = match ended { + Ok(ended) => ended, Err(unkept) => { tracing::error!( operator, @@ -315,14 +320,37 @@ async fn end_logins( ); } }; - // 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. + let reached: std::collections::BTreeSet<&str> = + ended.iter().map(|login| login.did.as_str()).collect(); + let mut accounts = Vec::with_capacity(reached.len()); + for did in reached { + match state + .registry + .record_logins_ended(did, &body.client, &operator) + { + Ok(entry) => accounts.push(Acted { + did: did.to_owned(), + ledger: Some(entry), + }), + Err(error) => { + tracing::error!( + operator, + client = body.client, + did, + %error, + "an operator ended an application's logins, and the account's ledger could \ + not record it" + ); + return refused_partway(&error, &accounts); + } + } + } tracing::info!( operator, client = body.client, account = account.as_deref().unwrap_or("every account"), - families, + families = ended.len(), + accounts = accounts.len(), codes, consents, "an operator ended an application's logins" @@ -332,9 +360,10 @@ async fn end_logins( "client": body.client, "account": account, "operator": operator, - "families": families, + "families": ended.len(), "codes": codes, "consents": consents, + "accounts": accounts, })) .into_response() } diff --git a/crates/didbot-serve/src/tests/dashboard_tests.rs b/crates/didbot-serve/src/tests/dashboard_tests.rs index 08617a9e..90209171 100644 --- a/crates/didbot-serve/src/tests/dashboard_tests.rs +++ b/crates/didbot-serve/src/tests/dashboard_tests.rs @@ -1159,6 +1159,225 @@ async fn ending_logins_the_grant_store_cannot_write_is_an_error() { let _ = std::fs::remove_dir_all(&dir); } +/// Provisions `account_id` beneath the server, and answers its DID and the +/// account token provisioning issued. +fn provision_with_token(pds: &dyn Registry, account_id: &str) -> (String, String) { + let provisioned = pds + .provision(ProvisionRequest::new(account_id, None)) + .unwrap_or_else(|err| panic!("provisioning {account_id} should succeed: {err}")); + ( + provisioned.account.did.as_str().to_owned(), + provisioned.account_token, + ) +} + +/// Revoking an account's tokens ends its account token and every app's +/// login on it, answers with the entry it appended, and leaves the +/// account's state and locks as they were. Asked again, it ends nothing and +/// appends nothing. +#[tokio::test] +async fn revoking_an_accounts_tokens_ends_them_all_and_names_the_operator() { + let pds = provisioner(); + let (did, token) = provision_with_token(pds.as_ref(), "kestrel"); + pds.lock(&did, Lock::Frozen, Actor::Operator(OPERATOR_DID)) + .expect("the operator freezes it first"); + 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(pds.clone(), oauth); + let uri = format!("/dashboard/api/accounts/{did}/revoke-tokens"); + + let (status, body) = send(&app, as_operator(&session, &uri, json!({}))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["action"], "revoke-tokens", "{body}"); + assert_eq!( + (body["tokens"].as_u64(), body["logins"].as_u64()), + (Some(1), Some(2)) + ); + let row = &body["accounts"][0]; + assert_eq!(row["did"], did, "{body}"); + assert_eq!(row["ledger"]["event"], "tokensRevoked", "{body}"); + assert_eq!(row["ledger"]["operator"], OPERATOR_DID, "{body}"); + let ledger = pds.ledger(&did).expect("a ledger"); + assert_eq!( + serde_json::to_value(ledger.entries.last()).expect("an entry serializes"), + row["ledger"], + "the answer is the entry the ledger holds" + ); + + assert!( + pds.verify_agent_token(&token).is_err(), + "the account token still verifies" + ); + assert!(tokens.live().is_empty(), "an app's login survived"); + let account = pds.account(&did).expect("the account stays"); + assert_eq!(account.state, AccountState::Active); + assert!( + account.locks.has(Lock::Frozen), + "the lock came off: {}", + account.locks + ); + + let (status, body) = send(&app, as_operator(&session, &uri, json!({}))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + (body["tokens"].as_u64(), body["logins"].as_u64()), + (Some(0), Some(0)) + ); + assert!(body["accounts"][0].get("ledger").is_none(), "{body}"); + assert_eq!( + pds.ledger(&did).expect("a ledger").entries.len(), + ledger.entries.len(), + "a revocation that ended nothing appended an entry" + ); +} + +/// The server's own account is refused, and so is one this server does not +/// hold. +#[tokio::test] +async fn revoking_tokens_refuses_the_servers_own_account_and_a_stranger() { + let pds = provisioner(); + let server = pds.service_did(); + let (app, session) = signed_in_dashboard(pds.clone()); + + let (status, body) = send( + &app, + as_operator( + &session, + &format!("/dashboard/api/accounts/{server}/revoke-tokens"), + json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["error"], "ServerAccount", "{body}"); + + let (status, body) = send( + &app, + as_operator( + &session, + "/dashboard/api/accounts/nobody.agents.localhost/revoke-tokens", + json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); +} + +/// A revocation the grant store cannot write down is an error that says a +/// restart may bring the logins back, and the ledger gains nothing. +#[tokio::test] +async fn revoking_tokens_the_grant_store_cannot_write_appends_nothing() { + let dir = std::env::temp_dir().join(format!( + "didbot-serve-unwritten-revocation-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + let durable = + didbot_pds::Durable::open(&dir, time::Duration::days(30)).expect("the directory opens"); + let pds = provisioner(); + let (did, token) = provision_with_token(pds.as_ref(), "kestrel"); + let before = pds.ledger(&did).expect("a ledger").entries.len(); + let oauth = oauth_over( + durable.oauth_grants(), + &[(APP, &did, "repo:com.example.thing")], + ); + std::fs::create_dir(dir.join(didbot_pds::oauth::GRANT_SCRATCH)).expect("the scratch is taken"); + let tokens = oauth.tokens.clone(); + let (app, session) = signed_in_dashboard_over(pds.clone(), oauth); + + let (status, body) = send( + &app, + as_operator( + &session, + &format!("/dashboard/api/accounts/{did}/revoke-tokens"), + json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}"); + assert_eq!(body["error"], "GrantStoreFailed", "{body}"); + assert!( + body["message"] + .as_str() + .is_some_and(|message| message.contains("may return after a restart")), + "{body}" + ); + assert_eq!(pds.ledger(&did).expect("a ledger").entries.len(), before); + assert!( + pds.verify_agent_token(&token).is_err(), + "the account token ended here" + ); + assert!( + tokens.live().is_empty(), + "the login ended here all the same" + ); + + drop(durable); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Ending an app's logins appends a `loginsEnded` entry, naming the operator +/// and the app, to each account a login ended on, in DID order, and answers +/// with them. An account signed in to another app gains nothing, and an +/// ending that reached no account answers none. +#[tokio::test] +async fn ending_an_apps_logins_answers_the_entry_each_account_gained() { + let pds = provisioner(); + let dids: Vec = ["marten", "kestrel", "shrike"] + .into_iter() + .map(|account_id| provision_with_token(pds.as_ref(), account_id).0) + .collect(); + let (marten, kestrel, shrike) = (&dids[0], &dids[1], &dids[2]); + let oauth = oauth_with_logins(&[ + (APP, marten, "repo:com.example.thing"), + (APP, kestrel, "repo:com.example.thing"), + (OTHER_APP, shrike, "repo:com.example.thing"), + ]); + let (app, session) = signed_in_dashboard_over(pds.clone(), oauth); + let end = || { + as_operator( + &session, + "/dashboard/api/apps/end-logins", + json!({ "client": APP }), + ) + }; + + let (status, body) = send(&app, end()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["families"], 2, "{body}"); + let rows = body["accounts"].as_array().expect("a row per account"); + let reached: Vec<&str> = rows.iter().filter_map(|row| row["did"].as_str()).collect(); + assert_eq!(reached, [kestrel.as_str(), marten.as_str()], "{body}"); + for row in rows { + let did = row["did"].as_str().expect("a did"); + assert_eq!(row["ledger"]["event"], "loginsEnded", "{body}"); + assert_eq!(row["ledger"]["clientId"], APP, "{body}"); + assert_eq!(row["ledger"]["operator"], OPERATOR_DID, "{body}"); + assert_eq!( + serde_json::to_value(pds.ledger(did).expect("a ledger").entries.last()) + .expect("an entry serializes"), + row["ledger"], + "{did}'s ledger does not end in the answered entry" + ); + } + assert!( + !pds.ledger(shrike) + .expect("a ledger") + .entries + .iter() + .any(|entry| matches!(entry.event, LedgerEvent::LoginsEnded { .. })), + "an account signed in to another app gained an entry" + ); + + let (status, body) = send(&app, end()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["families"], 0, "{body}"); + assert_eq!(body["accounts"], json!([]), "{body}"); +} + /// The application these panels are about. const APP: &str = "https://app.pds.example/client.json"; /// Another one, so a filter has something to exclude.