sec(auth): a mission's door token is revoked when the mission ends

The skills-door token was minted with a 24 h TTL and nothing revoked it
sooner, so a mission that finished in twenty minutes left a live
credential in its container for the rest of the day. auth_sessions gains
mission_id (ON DELETE CASCADE, so a purge revokes too);
mint_scoped_for_mission records it; revoke_mission_sessions deletes it.
Revocation runs on both terminal paths — the runner's close (RETURNING
the closed ids) and the operator's stop — and says how many it cleared.

Granularity is the mission, not the phase: the container and its door
are installed once per mission and serve every phase. Lingering
Authority (arXiv 2606.22504) is the reference. Tests: a minted token
authenticates for its scope and not as a full session, is dead after
revoke, and another mission's token is untouched; the harness gatepolicy
scenario now runs on the index arm and asserts the server revoked ≥1,
no row carries the mission, and the door answers 401 to the token.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-20 22:20:13 -05:00
co-authored by Claude Opus 5
parent 3909fa14ca
commit 2069bdf322
7 changed files with 246 additions and 7 deletions
+41
View File
@@ -376,6 +376,47 @@ impl AuthService {
Ok(token.secret().to_string())
}
/// As [`Self::mint_scoped`], bound to a mission: the row carries
/// `mission_id`, and [`Self::revoke_mission_sessions`] deletes it when
/// the mission ends. A 24 h TTL is the backstop, not the lifetime.
pub async fn mint_scoped_for_mission(
&self,
user_id: UserId,
scope: &str,
ttl: Duration,
mission_id: uuid::Uuid,
) -> Result<String, AuthError> {
if scope == SCOPE_FULL {
return Err(AuthError::Unauthenticated);
}
let token = SessionToken::generate();
sqlx::query(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at, scope, mission_id)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(hash_token(token.secret()))
.bind(user_id.as_uuid())
.bind(OffsetDateTime::now_utc() + ttl)
.bind(scope)
.bind(mission_id)
.execute(&self.pool)
.await?;
Ok(token.secret().to_string())
}
/// Revoke every session minted for a mission. Returns how many there
/// were; zero is the normal case for a mission that had no door.
pub async fn revoke_mission_sessions(
&self,
mission_id: uuid::Uuid,
) -> Result<u64, AuthError> {
let done = sqlx::query("DELETE FROM auth_sessions WHERE mission_id = $1")
.bind(mission_id)
.execute(&self.pool)
.await?;
Ok(done.rows_affected())
}
/// Mint a long-lived opaque session for an internal service caller
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
/// Returns the plaintext token — the caller is responsible for handing