diff --git a/MODULE.md b/MODULE.md index 4b3da76..94a2582 100644 --- a/MODULE.md +++ b/MODULE.md @@ -376,7 +376,7 @@ many subjects (SaaS tenants, derived embedded agents) registers each one later: | --- | --- | | `RegisterApprovalScope2` | Adds a scope. An identical repeat is `unchanged`; the same identity or the same subject with anything else is a conflict. A stale generation, bad grants or a malformed stream deny. | | `ReplaceApprovalScope2` | Atomically changes posture, grants or `policy_ref` of a live scope (init scopes included). The subject and, for runtime scopes, the audit stream cannot change. Rows bound to another `policy_ref` stop being decidable. | -| `RetireApprovalScope2` | Removes a scope; every later call under it denies and the ports release the subject. | +| `RetireApprovalScope2` | Removes a runtime scope (initialized scopes are replaced, never retired); every later call under it denies and the ports release the subject. A failed release keeps the scope registered so the retirement can be retried. | All three are private host-only members served to the authenticated init peer, like the other registrations. The table holds at most 65,536 live scopes. The diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 18cd86f..0cce543 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -207,6 +207,11 @@ impl ScopeTable { .insert(entry.scope.scope_id.clone(), Arc::new(entry)); fresh } + /// Put back an entry removed by a retirement the ports could not complete. + fn reinstate(&mut self, entry: Arc) { + self.subjects.insert(Self::key(&entry.scope)); + self.by_id.insert(entry.scope.scope_id.clone(), entry); + } fn remove(&mut self, id: &str) -> Option> { let removed = self.by_id.remove(id)?; self.subjects.remove(&Self::key(&removed.scope)); @@ -504,11 +509,7 @@ impl Engine { if reg.generation != self.generation.load(Ordering::SeqCst) { return Err(Error::Denied); } - // A grant or posture replaced while the ports were awaited must not publish - // an automatic approval from the obsolete snapshot. - if automatic && !Arc::ptr_eq(&entry, &self.entry(&request.scope_id)?) { - return Err(Error::Denied); - } + self.automatic_stands(&entry, &request.scope_id, automatic)?; let now = self.now()?; if reusable .as_ref() @@ -550,8 +551,23 @@ impl Engine { audit: request.audit, }; let row = self.memo_begin(row, deadline.is_some())?; - self.finish_native_begin(®, row, now, deadline.is_some()) - .await + let row = self + .finish_native_begin(®, row, now, deadline.is_some()) + .await?; + // The publication awaited too: never hand back an approval whose automatic + // authority was replaced meanwhile (consumption would refuse it anyway). + if row.state == ApprovalLifecycle2::Approved { + self.automatic_stands(&entry, &request.scope_id, automatic)?; + } + Ok(row) + } + /// An automatic approval stands only while the scope entry it was derived from is + /// still the live one: a grant or posture replaced during an await revokes it. + fn automatic_stands(&self, entry: &Arc, id: &str, automatic: bool) -> Result<()> { + if automatic && !Arc::ptr_eq(entry, &self.entry(id)?) { + return Err(Error::Denied); + } + Ok(()) } /// Who approved a row that needed no human: reviewed reuse, the judge, a host /// grant or the scope's own automatic posture. @@ -1212,7 +1228,15 @@ impl Engine { .map_err(|_| Error::Unavailable)? .remove(&request.scope_id) .ok_or(Error::Denied)?; - self.ports.release_scope(&removed.scope).await?; + if let Err(error) = self.ports.release_scope(&removed.scope).await { + // Keep ownership so the retirement can be retried; the ports fail closed + // for whatever they already released. + self.scopes + .write() + .map_err(|_| Error::Unavailable)? + .reinstate(removed); + return Err(error); + } let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; Self::receipt( &scopes, diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 4b1855a..2299987 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -316,7 +316,22 @@ async fn retire_denies_later_calls_releases_the_subject_and_reports_once() -> Re .err(), Some(Error::Unavailable) ); - // A failed release still leaves the scope unusable. + // A failed release keeps ownership: the subject cannot register again, and the + // retirement can be retried until the ports complete it. + let mut again = register(7); + again.scope.scope_id = id(8); + assert_eq!( + owner.register_scope(again).await.err(), + Some(Error::Conflict) + ); + fixture.release_fails.store(0, Ordering::SeqCst); + assert_eq!( + owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(7) }) + .await? + .outcome, + ApprovalScopeOutcome2::Retired + ); assert!( owner .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) @@ -930,3 +945,43 @@ async fn initialized_scopes_cannot_be_retired() -> Result<()> { ); Ok(()) } + +#[tokio::test] +async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req).await?; + let before = fixture.cas_calls.load(Ordering::SeqCst); + fixture + .barrier + .lock() + .map_err(|_| Error::Unavailable)? + .replace(Arc::new(tokio::sync::Barrier::new(2))); + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { + owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await + } + }); + // The new row's write is parked: the grant is revoked after the early checks. + while fixture.cas_calls.load(Ordering::SeqCst) == before { + tokio::task::yield_now().await; + } + owner.replace_scope(register(7))?; + let barrier = fixture + .barrier + .lock() + .map_err(|_| Error::Unavailable)? + .clone() + .ok_or(Error::Unavailable)?; + barrier.wait().await; + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + Ok(()) +} diff --git a/crates/tinysecurity-bus/src/approval_scopes.rs b/crates/tinysecurity-bus/src/approval_scopes.rs index 65eb012..1b6ea15 100644 --- a/crates/tinysecurity-bus/src/approval_scopes.rs +++ b/crates/tinysecurity-bus/src/approval_scopes.rs @@ -104,7 +104,7 @@ pub struct RegisterApprovalScopeRequest2 { pub audit_stream_id: String, } -/// Retire one runtime or init scope. +/// Retire one runtime scope. Initialized scopes can be replaced but never retired. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RetireApprovalScopeRequest2 {