diff --git a/crates/didbot-agentd/src/bin/didbot-oauth.rs b/crates/didbot-agentd/src/bin/didbot-oauth.rs index 5b504bc0..6a0db6f5 100644 --- a/crates/didbot-agentd/src/bin/didbot-oauth.rs +++ b/crates/didbot-agentd/src/bin/didbot-oauth.rs @@ -44,6 +44,10 @@ DIDBOT_AGENT_TOKEN_FILE) and the same commands talk to that server as that one account. --direct insists on it rather than trying the socket first. "; +/// How an approval's answer introduces what the ceiling grants of the +/// request at that moment, whichever mode it came through. +const GRANTED_NOW: &str = "granted now: "; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let command = args.first().map(String::as_str); @@ -155,11 +159,11 @@ fn approve(args: &[String]) -> ExitCode { return ExitCode::FAILURE; } println!("{}", answer.done.as_deref().unwrap_or("approved")); - // What was actually granted, which is not always what was asked - // for: a narrowed request grants less, and saying so here is the - // last chance the agent has to notice. + // What the ceiling grants now. The approval covers the whole + // request, so this is less than was asked only while the + // ceiling narrows it. if let Some(granted) = answer.granted { - println!("granted: {}", list(&granted)); + println!("{GRANTED_NOW}{}", list(&granted)); } ExitCode::SUCCESS } @@ -260,7 +264,7 @@ fn in_process(direct: &Direct, command: &str, args: &[String]) -> ExitCode { "signed in to {} as {}", record.client.origin, record.account ); - println!("granted: {}", list(&approved.granted)); + println!("{GRANTED_NOW}{}", list(&approved.granted)); ExitCode::SUCCESS } Err(err) => fail("approve", &err), diff --git a/crates/didbot-agentd/src/cli.rs b/crates/didbot-agentd/src/cli.rs index 3ec4db7b..7bcf9709 100644 --- a/crates/didbot-agentd/src/cli.rs +++ b/crates/didbot-agentd/src/cli.rs @@ -131,6 +131,14 @@ pub fn list(scopes: &[String]) -> String { } } +/// What a narrowed line adds after its rule: an approval covers everything +/// asked, and the ceiling is checked again at every use. +/// +/// So `granted` and `cut` are the ceiling's answer now. A loosened ceiling +/// grants the cut atoms to the same login without asking again, and a +/// tightened one takes granted atoms back. +const NARROW_APPROVES: &str = "approves=asked ceiling-checked=each-use"; + /// One decision on one line, with the token last. /// /// Last because it is the part that gets copied into the next command, and @@ -164,6 +172,10 @@ pub fn one_line(decision: &DecisionForAgent) -> String { if let Some(reason) = &decision.reason { line.push_str(&format!(" reason={reason:?}")); } + if decision.verdict == "narrow" { + line.push(' '); + line.push_str(NARROW_APPROVES); + } line.push_str(&format!(" expires={}", decision.expires_at)); match &decision.token { Some(token) => line.push_str(&format!(" token={token}")), @@ -320,6 +332,20 @@ mod tests { } } + #[test] + fn a_narrowed_line_says_the_approval_covers_everything_asked() { + // The ceiling is asked again at every use, so `cut` is withheld for + // now, not refused. An agent approving this approves `asked`. + let line = one_line(&decision()); + assert!( + line.contains(" approves=asked ceiling-checked=each-use "), + "{line}" + ); + // Commentary on the rule, so it follows it, and the token stays last. + assert!(line.find("rule=") < line.find("approves="), "{line}"); + assert!(line.ends_with("token=k7f3"), "{line}"); + } + #[test] fn one_that_was_refused_says_so_rather_than_offering_a_token() { let mut refused = decision(); @@ -341,6 +367,7 @@ mod tests { line.contains(r#"reason="that client is not admitted""#), "{line}" ); + assert!(!line.contains("approves="), "nothing to approve: {line}"); assert!(line.ends_with("token=none"), "{line}"); } @@ -356,5 +383,6 @@ mod tests { assert!(!line.contains("granted="), "{line}"); assert!(!line.contains("cut="), "{line}"); assert!(!line.contains("reason="), "{line}"); + assert!(!line.contains("approves="), "{line}"); } } diff --git a/crates/didbot-agentd/src/decisions.rs b/crates/didbot-agentd/src/decisions.rs index 36d9fe01..0bd287cb 100644 --- a/crates/didbot-agentd/src/decisions.rs +++ b/crates/didbot-agentd/src/decisions.rs @@ -103,14 +103,18 @@ pub struct Client { pub enum Verdict { /// Everything asked for may be granted. Allow, - /// Some of it may, and the rest was cut. Never silently: `cut` and `rule` + /// The ceiling grants some of it now. Never silently: `cut` and `rule` /// are what makes it possible to say so. + /// + /// An approval still covers the whole request. The server asks the + /// ceiling again at every use, so both sets move with it. Narrow { - /// What may be granted. + /// What the ceiling grants now. granted: Vec, - /// What was taken out of the request. + /// What the ceiling withholds now. A loosened ceiling grants it to + /// the approved login without asking again. cut: Vec, - /// The rule that took it. + /// The rule that withholds it. rule: String, }, /// None of it may. @@ -144,7 +148,7 @@ impl Record { } } - /// The scopes that would actually be granted. + /// The scopes the ceiling grants now. pub fn granted(&self) -> Vec { match &self.verdict { Verdict::Allow => self.requested.clone(), @@ -238,8 +242,8 @@ pub struct Page { pub struct Approved { /// The request that was approved. pub request_uri: String, - /// The scopes the code was issued at, which is the granted set and not - /// necessarily the requested one. + /// What the ceiling grants of the request now. The code carries the + /// whole request, and the ceiling is asked again at every use. #[serde(default)] pub granted: Vec, /// The client's own callback, with the code on it, for the daemon to diff --git a/crates/didbot-agentd/src/direct.rs b/crates/didbot-agentd/src/direct.rs index 2f7d3c47..11b2850c 100644 --- a/crates/didbot-agentd/src/direct.rs +++ b/crates/didbot-agentd/src/direct.rs @@ -123,9 +123,9 @@ impl Direct { /// Approve, and hand the client its code. /// - /// Returns what was granted alongside the record, because a narrowed - /// request grants less than it asked for and the caller should be able to - /// say so. + /// Returns what the ceiling grants now alongside the record, because a + /// narrowed request grants less than it asked for while the ceiling + /// holds, and the caller should be able to say so. pub async fn approve(&self, named: Named<'_>) -> Result<(Approved, Record), String> { let record = self.resolve(named).await?; let token = spendable(&record)?; diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index fdb034dc..b742cc93 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -249,11 +249,18 @@ pub struct DecisionForAgent { pub client_origin: String, /// Whether this account has seen that client before. pub first_time: bool, - /// What was asked for. + /// What was asked for, and what an approval covers. pub requested: Vec, - /// What would actually be granted. + /// What the ceiling grants of the request now. + /// + /// The ceiling is asked again at every use, so a tightened one takes + /// atoms back from a login already approved. pub granted: Vec, - /// What was taken out of the request, empty unless it was narrowed. + /// What the ceiling withholds of the request now, empty unless it was + /// narrowed. + /// + /// Withheld, not refused: the approval covers the whole request, so a + /// loosened ceiling grants these to the same login without asking again. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub cut: Vec, /// The rule that narrowed or refused it, named on its own. @@ -329,8 +336,8 @@ pub struct Answer { /// Sign-ins waiting on a decision, for the context that asked. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending: Option>, - /// What an approval actually granted, which is not always what was asked - /// for: a narrowed request grants less, and says so here. + /// What the approved login may do now: the request as the ceiling grants + /// it, which is less than was asked for while the ceiling narrows it. #[serde(default, skip_serializing_if = "Option::is_none")] pub granted: Option>, /// What this host is, in answer to a [`Host`] question. diff --git a/docs/agentd.md b/docs/agentd.md index b3e92538..3245151a 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -90,7 +90,7 @@ hook. long-polled, one task per account the daemon calls out, never in approveAuthorization - redeems a token, at the granted scopes + redeems a token, for the whole request provision @@ -233,6 +233,12 @@ refused rather than passed on. Approving as the account means presenting that ac token, which the daemon has held in memory since provisioning and writes nowhere. +An approval covers the whole request. The server asks its scope ceiling again +at every use, so a narrowed decision's `granted` and `cut` are the ceiling's +answer now. A loosened ceiling grants the cut atoms to the same login without +asking again, and a tightened one takes granted atoms back. A narrowed line +says so with `approves=asked ceiling-checked=each-use`. + The server cannot deliver the resulting code — the client is listening on loopback on this host — so it answers with the redirect and the daemon fetches it. That fetch is bounded to loopback, with redirects turned off, as is every