From 3e6c231c8bd89ee592ba4fe559fea68c65924a01 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 15:41:57 -0400 Subject: [PATCH] feat(operate)!: admit with the operator session, not an rpc scope didbot operate asks the operator's own PDS for the bot.did.operator repo scope alone, and the create at the server carries the operator session didbot login stored for it, signing in first when none is kept. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I1f986b9abab135b6c7f3c2c0964c37c3070a47a1 --- .../src/bin/didbot-operator.rs | 16 +- crates/didbot-operator/src/lib.rs | 104 +++++++--- crates/didbot-operator/src/operate.rs | 67 +++++-- crates/didbot-operator/src/operate/account.rs | 189 +++++++++++------- crates/didbot-operator/src/operate/scope.rs | 57 ++---- crates/didbot-operator/src/operate/write.rs | 88 -------- 6 files changed, 268 insertions(+), 253 deletions(-) diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index 175a3ddf..60f6425a 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -71,12 +71,13 @@ enum Verb { A server's own hostname is the top edge: the command waits for the \ server to answer over TLS and checks its identity first. Any other name \ is an account beneath a server your repository already names, chosen by \ - the name's suffix or with --server: your own PDS mints a service-auth \ - token for one call, and the server creates the account. A key `didbot \ - register` parked under the name is shown for you to confirm, or named by \ - --fingerprint, and becomes the account's; otherwise say what it \ - is with --kind, and --oidc if a token from an issuer is how it will \ - sign in. The record names both as what logs in. --creates lets the \ + the name's suffix or with --server. The server creates the account \ + when asked with the operator session `didbot login` kept for it; with \ + none kept, the command signs in the way `didbot login` does first. A \ + key `didbot register` parked under the name is shown for you to \ + confirm, or named by --fingerprint, and becomes the account's; \ + otherwise say what it is with --kind, and --oidc if a token from an \ + issuer is how it will sign in. The record names both as what logs in. --creates lets the \ account create beneath itself and --creates-beneath lets everything \ beneath it create; each names the kinds allowed, or any kind with \ none named.\n\n\ @@ -118,7 +119,8 @@ enum Verb { #[command( long_about = "Prints a URL to open. The sign-in happens on your own atproto account and \ comes back to a loopback address this command listens on; the deployment \ - mints the session, and it is kept for estop and announce." + mints the session, and it is kept for operate, estop, account and \ + announce." )] Login, /// Read or set the deployment's emergency stop diff --git a/crates/didbot-operator/src/lib.rs b/crates/didbot-operator/src/lib.rs index 476305b4..273fc351 100644 --- a/crates/didbot-operator/src/lib.rs +++ b/crates/didbot-operator/src/lib.rs @@ -4,7 +4,7 @@ //! Two credentials, two flows. [`operate`] is the claim: it authenticates to //! the operator's own atproto account and writes `bot.did.operator` there, //! which is what makes a deployment theirs. The rest of this crate is the -//! deployment's controls. `CLAUDE.md`'s rule for this project is that an +//! deployment's controls, and an admission's create at the deployment. `CLAUDE.md`'s rule for this project is that an //! operator command is available on the web dashboard *or* from a command //! line, using the same auth, and this is the second half of that: every //! control verb is one HTTP call to a `/dashboard/api/*` route, and the @@ -89,15 +89,34 @@ pub fn default_session_path() -> Option { /// Reads the session for `server`, if one is stored. pub fn load(path: &Path, server: &str) -> Result { + read_sessions(path)? + .into_iter() + .find(|held| held.server == server) + .ok_or_else(|| OperatorError::NotSignedIn(server.to_owned())) +} + +/// Reads the session for the deployment at `hostname`, stored under that +/// name or under it with the port `didbot login --server` was given. +pub fn load_for_host(path: &Path, hostname: &str) -> Result { + let sessions = read_sessions(path)?; + let exact = sessions.iter().position(|held| held.server == hostname); + let ported = || { + sessions + .iter() + .position(|held| didbot_identity::did::host_without_port(&held.server) == hostname) + }; + match exact.or_else(ported) { + Some(index) => Ok(sessions[index].clone()), + None => Err(OperatorError::NotSignedIn(hostname.to_owned())), + } +} + +fn read_sessions(path: &Path) -> Result, OperatorError> { let raw = std::fs::read_to_string(path).map_err(|source| OperatorError::Store { path: path.to_owned(), source, })?; - let sessions: Vec = serde_json::from_str(&raw).unwrap_or_default(); - sessions - .into_iter() - .find(|held| held.server == server) - .ok_or_else(|| OperatorError::NotSignedIn(server.to_owned())) + Ok(serde_json::from_str(&raw).unwrap_or_default()) } /// Stores `session`, replacing any for the same deployment. @@ -186,10 +205,19 @@ fn parse_handoff(server: &str, request_line: &str) -> Option { }) } -/// Calls one operator route, carrying `session`. -/// -/// `body` present means a mutating call, which also carries the CSRF token -/// the session was minted with — see `didbot_serve::operator`. +/// Carries `session` on `request`: the session cookie, and the CSRF token +/// a mutating call must echo — see `didbot_serve::operator`. +pub fn present(request: reqwest::RequestBuilder, session: &Session) -> reqwest::RequestBuilder { + request + .header( + reqwest::header::COOKIE, + format!("didbot_operator={}", session.token), + ) + .header("x-didbot-csrf", &session.csrf) +} + +/// Calls one operator route, carrying `session`. `body` present means a +/// `POST`. pub async fn call( session: &Session, path: &str, @@ -198,23 +226,17 @@ pub async fn call( let url = format!("https://{}{path}", session.server); let client = didbot_http::client(); let request = match &body { - Some(body) => client - .post(&url) - .header("x-didbot-csrf", &session.csrf) - .json(body), + Some(body) => client.post(&url).json(body), None => client.get(&url), }; - let response = request - .header( - reqwest::header::COOKIE, - format!("didbot_operator={}", session.token), - ) - .send() - .await - .map_err(|source| OperatorError::Unreachable { - server: session.server.clone(), - source, - })?; + let response = + present(request, session) + .send() + .await + .map_err(|source| OperatorError::Unreachable { + server: session.server.clone(), + source, + })?; let status = response.status(); let text = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY) .await @@ -308,6 +330,38 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// `didbot login --server` is typed with the port a development + /// deployment answers on, and an admission knows the server only by + /// its hostname: the session is found either way. + #[test] + fn a_session_is_found_by_its_hostname_with_or_without_a_port() { + let dir = std::env::temp_dir().join(format!("didbot-operator-host-{}", std::process::id())); + let path = dir.join("operator.json"); + for server in ["one.localhost:8443", "two.example"] { + store( + &path, + Session { + server: server.to_owned(), + token: server.to_owned(), + csrf: "beta".to_owned(), + }, + ) + .expect("stores"); + } + + let found = |host: &str| load_for_host(&path, host).map(|held| held.token); + assert_eq!( + found("one.localhost").ok().as_deref(), + Some("one.localhost:8443") + ); + assert_eq!(found("two.example").ok().as_deref(), Some("two.example")); + assert!(matches!( + found("localhost"), + Err(OperatorError::NotSignedIn(_)) + )); + std::fs::remove_dir_all(&dir).ok(); + } + /// The handoff is the whole point of the loopback listener, and a /// browser's request line is the only place the session arrives. /// diff --git a/crates/didbot-operator/src/operate.rs b/crates/didbot-operator/src/operate.rs index 848f0b77..951d12de 100644 --- a/crates/didbot-operator/src/operate.rs +++ b/crates/didbot-operator/src/operate.rs @@ -19,7 +19,8 @@ //! own account, kept in [`FileAuthStore`]'s file between runs so a second //! host is one command and no browser. It is not the deployment session //! [`crate::login`] mints: that one is checked by the deployment, this one by -//! the operator's own PDS, and neither verb can use the other's. +//! the operator's own PDS. An admission carries both, each to its own +//! server. //! //! [`check`] runs the read-only half alone: it resolves the hostname, opens //! HTTPS to it, reads its `did:web` document and cross-checks @@ -45,12 +46,14 @@ //! claimed: `didbot operate kestrel.pds.example --kind agent`. Which server //! is read from the operator's own repository ([`under`]): the name sits //! under a server they hold a `bot.did.operator` record for, and nothing -//! that answers at the name gets a say. The sign-in asks for the scope -//! [`scope::admit_scope`] builds for that one server, the operator's own -//! PDS mints a service-auth token for one call there, and the server -//! creates the account. [`account`] is that half; [`intent`] tells a claim -//! from an admission before anything signs in. [`walk`] is `--check` for an -//! account: every edge from it up to the operator, read with no credential. +//! that answers at the name gets a say. The sign-in to the operator's own +//! PDS asks for [`scope::operator_write_scope`], the same scope a claim +//! asks for. The create at the server carries the operator session +//! `didbot login` stored for it, and a run with none signs in the way +//! `didbot login` does first. [`account`] is that half; [`intent`] tells a +//! claim from an admission before anything signs in. [`walk`] is `--check` +//! for an account: every edge from it up to the operator, read with no +//! credential. //! //! # Idempotence //! @@ -108,7 +111,7 @@ use self::identify::HttpDocumentFetcher; use self::orchestrate::ClaimOutcome; use self::preflight::{preflight, Report}; use self::tls::ReqwestTlsProbe; -use self::write::{RecordDeleter, RecordReader, RecordWriter, ServiceAuthMinter}; +use self::write::{RecordDeleter, RecordReader, RecordWriter}; /// How long a claim waits for TLS before giving up and telling the operator /// to retry. A freshly applied deployment's first ACME DNS-01 order can take @@ -179,6 +182,10 @@ pub enum OperateError { /// No server the operator holds a record for has the name under it. #[error(transparent)] Under(#[from] under::UnderError), + /// The operator session for the deployment could not be read, or the + /// sign-in that would mint one did not finish. + #[error(transparent)] + Deployment(#[from] crate::OperatorError), /// Signed in, and the account was not admitted. #[error(transparent)] Admit(#[from] account::AdmitError), @@ -367,16 +374,17 @@ pub async fn admit( // read with no credential; the name itself is never asked. let (resolved_did, pds) = resolve_operator(&store, operator).await?; let held = under::operator_record_keys(&document_fetcher, &pds, &resolved_did).await?; - let server = under::server_for(name, &held, server)?; - let server = AccountServer::for_host(&server, http); + let hostname = under::server_for(name, &held, server)?; + let server = AccountServer::for_host(&hostname, http); progress(format!( "{name} is under {}, which {resolved_did} operates", server.did() )); + let deployment = deployment_session(&hostname, &mut progress).await?; let (operator_did, session) = sign_in( operator, - &scope::admit_scope(server.did()), + &scope::operator_write_scope(), &store, &mut progress, ) @@ -391,6 +399,7 @@ pub async fn admit( progress(format!("admitting {name} beneath {operator_did}")); Ok(account::admit( &server, + &deployment, &session, &operator_did, name, @@ -426,6 +435,34 @@ pub async fn revoke( Ok(account::revoke(&session, &operator_did, name).await?) } +/// The operator session for the deployment at `hostname`: the one `didbot +/// login` stored, or, with none stored, a sign-in the way `didbot login` +/// runs one, kept for the next run. +async fn deployment_session( + hostname: &str, + progress: &mut impl FnMut(String), +) -> Result { + let path = crate::default_session_path().ok_or(OperateError::NoHome)?; + match crate::load_for_host(&path, hostname) { + Ok(session) => return Ok(session), + Err(crate::OperatorError::NotSignedIn(_)) => {} + Err(crate::OperatorError::Store { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => {} + Err(other) => return Err(other.into()), + } + // `didbot login` is told the address a person types, port and all. + let origin = account::origin_of(hostname); + let server = origin.trim_start_matches("https://").to_owned(); + progress(format!( + "no operator session for {server} is kept; signing in as its operator" + )); + let session = tokio::task::spawn_blocking(move || crate::login(&server)) + .await + .map_err(|error| crate::OperatorError::Wait(error.to_string()))??; + crate::store(&path, session.clone())?; + Ok(session) +} + /// The file the operator's session with their own PDS is kept in. fn open_store() -> Result { let sessions_path = @@ -503,13 +540,7 @@ async fn sign_in( scope_string: &str, store: &FileAuthStore, progress: &mut impl FnMut(String), -) -> Result< - ( - String, - impl RecordWriter + RecordDeleter + RecordReader + ServiceAuthMinter, - ), - OperateError, -> { +) -> Result<(String, impl RecordWriter + RecordDeleter + RecordReader), OperateError> { // Authenticate to the operator's *own* atproto account, with a scope no // wider than this run needs. This is the one network exchange here that // is genuinely interactive -- a browser window, the operator's own PDS's diff --git a/crates/didbot-operator/src/operate/account.rs b/crates/didbot-operator/src/operate/account.rs index 28ec5193..e14fb1e4 100644 --- a/crates/didbot-operator/src/operate/account.rs +++ b/crates/didbot-operator/src/operate/account.rs @@ -1,24 +1,21 @@ //! Admitting an account beneath the operator: `didbot operate ` for //! a name that is not a server. //! -//! The human proves themself to the server without a credential of theirs -//! ever reaching it. Their own PDS mints a service-auth token for one call -//! at the server; the record naming the account goes into the human's own -//! repository; then the server is asked to create, and verifies the token -//! against the key the human's DID document names. What the server sees is -//! a token it can check and a record it can read, and nothing it could -//! replay. +//! No credential of the operator's own account reaches the server. The +//! record naming the account goes into the operator's own repository; then +//! the server is asked to create, carrying the operator session the server +//! minted when the operator ran `didbot login`. The session says who is +//! asking, and the record, which the server reads, is what lets them +//! create. //! -//! The token comes first because a PDS that will not mint it -- the -//! session's grant is too narrow, or it has aged out -- refuses before any -//! record exists. A server that then refuses the create has the record -//! taken back -- deleted, or put back as it stood before the run -- so a -//! refused admission leaves nothing behind and takes nothing away. +//! A server that refuses the create has the record taken back -- deleted, +//! or put back as it stood before the run -- so a refused admission leaves +//! nothing behind and takes nothing away. //! -//! [`admit`] is the whole flow after sign-in, over two seams: the human's -//! PDS as [`RecordWriter`], [`RecordReader`], [`RecordDeleter`] and -//! [`ServiceAuthMinter`], and the server as -//! [`AccountServer`], which is tested against an in-process router. +//! [`admit`] is the whole flow after sign-in, over two seams: the +//! operator's PDS as [`RecordWriter`], [`RecordReader`] and +//! [`RecordDeleter`], and the server as [`AccountServer`], which is tested +//! against an in-process router. use std::fmt; @@ -28,11 +25,10 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::operate::record; -use crate::operate::write::{ - RecordDeleter, RecordReader, RecordWriter, ServiceAuthMinter, WriteError, -}; +use crate::operate::write::{RecordDeleter, RecordReader, RecordWriter, WriteError}; +use crate::Session; -/// The one method the service-auth token is for. +/// The method an admission calls at the server. pub const CREATE_ACCOUNT: &str = "bot.did.createAccount"; /// What `register` parked under a name, as the server describes it. @@ -141,7 +137,7 @@ impl AccountServer { ) } - /// The server's DID, which the service-auth token names as its audience. + /// The server's DID. pub fn did(&self) -> &str { &self.did } @@ -175,19 +171,15 @@ impl AccountServer { }) } - /// Creates the account `request` names, `credential` being the - /// service-auth token the human's PDS minted. + /// Creates the account `request` names, carrying the operator session + /// `didbot login` stored for this server. pub async fn create_account( &self, - credential: &str, + session: &Session, request: &CreateAccount, ) -> Result { let url = self.url(CREATE_ACCOUNT); - let response = self - .client - .post(&url) - .bearer_auth(credential) - .json(request) + let response = crate::present(self.client.post(&url).json(request), session) .send() .await .map_err(|err| ServerError::Unreachable { @@ -316,7 +308,7 @@ pub enum AdmitError { /// The server could not say what is parked; nothing was written. #[error("could not ask the server what is parked: {0}")] Park(#[source] ServerError), - /// The human's own PDS did not do its half; nothing was written. + /// The operator's own PDS did not do its half; nothing was created. #[error(transparent)] OwnPds(#[from] WriteError), /// The server did not create the account. @@ -356,8 +348,8 @@ pub struct Admitted { pub account_token: String, } -/// Admits `name` beneath `operator_did`: the service-auth token first, then -/// the record, then the create; a refused create takes the record back. +/// Admits `name` beneath `operator_did`: the record, then the create, +/// carrying `session`; a refused create takes the record back. /// A record that already stood at the key -- a second run for a name /// admitted before -- is put back as it was, so a refusal never costs an /// existing account its record. @@ -365,10 +357,11 @@ pub struct Admitted { /// A parked key is admitted by `fingerprint` -- `--fingerprint`, copied from /// what `register` printed -- whichever park under the name is newest, or, /// with none given, when `confirm` is shown the newest park and says yes. -/// Nothing is minted or written until one of those has happened. +/// Nothing is written until one of those has happened. #[allow(clippy::too_many_arguments)] -pub async fn admit( +pub async fn admit( server: &AccountServer, + session: &Session, own_pds: &W, operator_did: &str, name: &str, @@ -428,7 +421,6 @@ pub async fn admit created, Err(source) => { let taken_back = match &previous { @@ -597,12 +589,11 @@ mod tests { use super::*; - /// The human's own PDS, as this flow sees it: what was written, what - /// token was asked for, and in what order. + /// The operator's own PDS, as this flow sees it: what was written, and + /// in what order. #[derive(Default)] pub(super) struct OwnPds { written: Mutex>, - minted: Mutex>, deleted: Mutex>, /// Every call, in the order it came. log: Mutex>, @@ -664,17 +655,6 @@ mod tests { } } - impl ServiceAuthMinter for OwnPds { - async fn service_auth(&self, aud: &str, lxm: &str) -> Result { - self.log.lock().unwrap().push("mint"); - self.minted - .lock() - .unwrap() - .push((aud.to_owned(), lxm.to_owned())); - Ok("service-auth-token".to_owned()) - } - } - impl OwnPds { /// Every `(repo, rkey)` a delete reached. pub(super) fn deleted_records(&self) -> Vec<(String, String)> { @@ -738,14 +718,24 @@ mod tests { Json(json!({ "error": error, "message": "no" })), ); } - let bearer = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or_default() - .to_owned(); + let presented = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() + }; + let presented = (presented("cookie"), presented("x-didbot-csrf")); + if presented != ("didbot_operator=signed-in".to_owned(), "csrf".to_owned()) + || headers.contains_key("authorization") + { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "NotSignedIn", "message": "no" })), + ); + } let name = body["name"].as_str().unwrap_or_default().to_owned(); - served.created.lock().unwrap().push((bearer, body)); + served.created.lock().unwrap().push((presented.0, body)); ( StatusCode::OK, Json(json!({ @@ -782,12 +772,20 @@ mod tests { const NOW: OffsetDateTime = datetime!(2026-01-01 00:05:00 UTC); - /// The hand-off from `register`: the park is shown to the human, the - /// token is minted for this server and this one method before the - /// record goes in, and the create names the park and the park's kind - /// -- not the `--kind` typed. + /// The operator session `didbot login` stored for the server. + fn session() -> Session { + Session { + server: "pds.example".to_owned(), + token: "signed-in".to_owned(), + csrf: "csrf".to_owned(), + } + } + + /// The hand-off from `register`: the park is shown to the operator, the + /// record goes in, and the create carries the operator session and + /// names the park and the park's kind -- not the `--kind` typed. #[tokio::test] - async fn a_parked_key_is_confirmed_then_the_token_the_record_and_the_create_in_that_order() { + async fn a_parked_key_is_confirmed_then_the_record_and_the_create_in_that_order() { let served = Arc::new(Served { parked: Mutex::new(Some(park("h1.pds.example", "host"))), ..Default::default() @@ -798,6 +796,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -823,7 +822,7 @@ mod tests { .map(|p| p.fingerprint.as_str()), Some("SHA256:abc") ); - assert_eq!(own.log.lock().unwrap().as_slice(), ["mint", "read", "put"]); + assert_eq!(own.log.lock().unwrap().as_slice(), ["read", "put"]); let written = own.written.lock().unwrap(); assert_eq!(written.len(), 1); let (operator, rkey, claim) = &written[0]; @@ -831,13 +830,9 @@ mod tests { assert_eq!(rkey, "h1.pds.example"); assert_eq!(claim.subject, "did:web:h1.pds.example"); assert!(claim.creates.is_empty(), "nothing was asked for"); - assert_eq!( - own.minted.lock().unwrap().as_slice(), - [("did:web:pds.example".to_owned(), CREATE_ACCOUNT.to_owned())] - ); let created = served.created.lock().unwrap(); - let (bearer, body) = &created[0]; - assert_eq!(bearer, "service-auth-token"); + let (cookie, body) = &created[0]; + assert_eq!(cookie, "didbot_operator=signed-in"); assert_eq!( body, &json!({ "name": "h1.pds.example", "kind": "host", "parkedKey": "SHA256:abc" }) @@ -863,6 +858,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -900,6 +896,7 @@ mod tests { ] { let admitted = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -931,6 +928,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -968,6 +966,7 @@ mod tests { let own = OwnPds::default(); let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -980,7 +979,7 @@ mod tests { .unwrap_err(); assert_eq!( own.log.lock().unwrap().as_slice(), - ["mint", "read", "put", "delete"] + ["read", "put", "delete"] ); assert_eq!( own.deleted.lock().unwrap().as_slice(), @@ -1004,6 +1003,7 @@ mod tests { }; let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -1030,6 +1030,50 @@ mod tests { assert!(own.deleted.lock().unwrap().is_empty()); } + /// A session the server does not accept -- it aged out, or the server + /// restarted since `didbot login` -- is a refused create like any + /// other: the record comes back out. + #[tokio::test] + async fn a_session_the_server_refuses_takes_the_record_back() { + let served = Arc::new(Served::default()); + let (server, _task) = serve(Arc::clone(&served)).await; + let own = OwnPds::default(); + let stale = Session { + token: "aged-out".to_owned(), + ..session() + }; + + let err = admit( + &server, + &stale, + &own, + "did:plc:human", + "kestrel.pds.example", + Ask { + kind: Some("agent".to_owned()), + ..Default::default() + }, + None, + |_| true, + NOW, + ) + .await + .unwrap_err(); + + assert!( + matches!( + &err, + AdmitError::Server { + source: ServerError::Refused { status: 401, .. }, + record: Rollback::Removed { .. }, + } + ), + "{err}" + ); + assert_eq!(own.calls(), ["read", "put", "delete"]); + assert!(served.created.lock().unwrap().is_empty()); + } + /// A second run for a name the repository already names: the server /// refuses because the account exists, and the record that stood /// before the run is put back rather than deleted, so the account it @@ -1058,6 +1102,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -1109,6 +1154,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1128,6 +1174,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1159,6 +1206,7 @@ mod tests { *served.parked.lock().unwrap() = Some(park("deploy.pds.example", "host")); let admitted = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1201,6 +1249,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "laptop.pds.example", diff --git a/crates/didbot-operator/src/operate/scope.rs b/crates/didbot-operator/src/operate/scope.rs index eeab2544..4e59449a 100644 --- a/crates/didbot-operator/src/operate/scope.rs +++ b/crates/didbot-operator/src/operate/scope.rs @@ -19,35 +19,16 @@ //! server refuses, so a refused create leaves no record naming an account //! that does not exist. //! -//! Admitting an account also has the operator's PDS mint a service-auth -//! token for one call at the server. The atproto permission spec -//! (, "rpc") covers -//! `com.atproto.server.getServiceAuth` under the `rpc:` resource, and -//! requires the audience in the string form, so [`admit_scope`] binds the -//! token's one method to the one server it is for. +//! A claim, an admission and a revocation all ask for this one scope. The +//! create at the server carries the operator session `didbot login` mints +//! there, which the operator's own PDS plays no part in. use didbot_scope::{Action, ActionSet, NsidPattern, Scope, ScopeSet}; -/// The scope string a server claim requests: +/// The scope string every sign-in to the operator's own PDS requests: /// `"atproto repo:bot.did.operator?action=create&action=update&action=delete"`. pub fn operator_write_scope() -> String { - ScopeSet::new(write_atoms()).to_string() -} - -/// The scope string an account admission requests: [`operator_write_scope`] -/// plus `rpc:bot.did.createAccount?aud=`, so the PDS mints a -/// service-auth token for that one method at that one server. -pub fn admit_scope(server_did: &str) -> String { - let mut atoms = write_atoms(); - atoms.push(Scope::Rpc { - method: NsidPattern::Exact(crate::operate::account::CREATE_ACCOUNT.to_owned()), - aud: Some(server_did.to_owned()), - }); - ScopeSet::new(atoms).to_string() -} - -fn write_atoms() -> Vec { - vec![ + ScopeSet::new(vec![ Scope::Atproto, Scope::Repo { collection: NsidPattern::Exact(crate::operate::record::COLLECTION.to_owned()), @@ -57,7 +38,8 @@ fn write_atoms() -> Vec { .collect(), ), }, - ] + ]) + .to_string() } #[cfg(test)] @@ -73,30 +55,15 @@ mod tests { ); } - #[test] - fn an_admission_binds_the_service_auth_method_to_one_server() { - let scope = admit_scope("did:web:pds.example"); - assert_eq!( - scope, - "atproto repo:bot.did.operator?action=create&action=update&action=delete \ - rpc:bot.did.createAccount?aud=did:web:pds.example" - ); - assert!( - !admit_scope("did:web:other.example").contains("aud=did:web:pds.example"), - "the audience is the server asked for and no other" - ); - } - #[test] fn round_trips_through_the_servers_own_parser_and_jacquards() { // Not just well-formed by construction -- also accepted by the same // grammar a server enforcing a scope ceiling would parse it with, // and by the OAuth client that sends it. - for scope in [operator_write_scope(), admit_scope("did:web:pds.example")] { - let parsed = ScopeSet::parse(&scope).expect("the grammar we built it from parses it"); - assert_eq!(parsed.to_string(), scope); - jacquard_oauth::scopes::Scopes::new(smol_str::SmolStr::new(&scope)) - .expect("jacquard parses what it is asked to request"); - } + let scope = operator_write_scope(); + let parsed = ScopeSet::parse(&scope).expect("the grammar we built it from parses it"); + assert_eq!(parsed.to_string(), scope); + jacquard_oauth::scopes::Scopes::new(smol_str::SmolStr::new(&scope)) + .expect("jacquard parses what it is asked to request"); } } diff --git a/crates/didbot-operator/src/operate/write.rs b/crates/didbot-operator/src/operate/write.rs index 5a55cd90..c8e9179e 100644 --- a/crates/didbot-operator/src/operate/write.rs +++ b/crates/didbot-operator/src/operate/write.rs @@ -127,41 +127,6 @@ impl XrpcRequest for GetRecordParams { type Response = GetRecordResponse; } -/// `com.atproto.server.getServiceAuth`'s parameters: the audience, and the -/// one method the token is for. -#[derive(Debug, Serialize)] -struct GetServiceAuthParams { - aud: String, - lxm: String, -} - -/// The one field this command reads out of a minted service-auth token. -#[derive(Debug, Deserialize)] -pub struct ServiceAuthOutput { - /// The token. - pub token: String, -} - -/// The error shape `getServiceAuth` answers with; see [`PutRecordErr`]. -#[derive(Debug, Deserialize, Serialize, thiserror::Error)] -#[error("{0}")] -struct ServiceAuthErr(SmolStr); - -struct GetServiceAuthResponse; - -impl XrpcResp for GetServiceAuthResponse { - const NSID: &'static str = "com.atproto.server.getServiceAuth"; - const ENCODING: &'static str = "application/json"; - type Output = ServiceAuthOutput; - type Err = ServiceAuthErr; -} - -impl XrpcRequest for GetServiceAuthParams { - const NSID: &'static str = "com.atproto.server.getServiceAuth"; - const METHOD: XrpcMethod = XrpcMethod::Query; - type Response = GetServiceAuthResponse; -} - /// Why the write did not happen. #[derive(Debug, thiserror::Error)] pub enum WriteError { @@ -184,18 +149,6 @@ pub enum WriteError { /// What the server said. detail: String, }, - /// The operator's PDS would not mint a service-auth token for the - /// server. - #[error( - "the operator's PDS would not mint a service-auth token for {aud} ({detail}) -- the \ - session's `atproto` scope is what allows this; sign in again if it has aged out." - )] - ServiceAuth { - /// The server the token was for. - aud: String, - /// What the PDS said. - detail: String, - }, /// The record that may already stand at the key could not be read /// back, so nothing was written over it. #[error("could not read bot.did.operator/{rkey} back from the operator's PDS ({detail})")] @@ -311,17 +264,6 @@ pub trait RecordDeleter { ) -> Result<(), WriteError>; } -/// Mints a service-auth token at the operator's own PDS: `aud` is the -/// server it is for and `lxm` the one method it may call, and the PDS signs -/// it with the key the operator's DID document names. The other thing the -/// same session does, beside [`RecordWriter`], and the same fakes stand in -/// for both. -#[allow(async_fn_in_trait)] -pub trait ServiceAuthMinter { - /// The token, as the PDS minted it. - async fn service_auth(&self, aud: &str, lxm: &str) -> Result; -} - impl RecordWriter for jacquard_oauth::client::OAuthSession where T: jacquard_oauth::resolver::OAuthResolver @@ -392,33 +334,3 @@ where Ok(()) } } - -impl ServiceAuthMinter for jacquard_oauth::client::OAuthSession -where - T: jacquard_oauth::resolver::OAuthResolver - + jacquard_oauth::dpop::DpopExt - + jacquard_common::xrpc::XrpcExt - + Send - + Sync - + 'static, - S: jacquard_oauth::authstore::ClientAuthStore + Send + Sync + 'static, - W: Send + Sync, -{ - async fn service_auth(&self, aud: &str, lxm: &str) -> Result { - let refused = |error: &dyn std::fmt::Display| WriteError::ServiceAuth { - aud: aud.to_owned(), - detail: error.to_string(), - }; - let response = self - .send(GetServiceAuthParams { - aud: aud.to_owned(), - lxm: lxm.to_owned(), - }) - .await - .map_err(|error| refused(&error))?; - let output = response - .parse::() - .map_err(|error| refused(&error))?; - Ok(output.token) - } -} -- 2.51.2