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
+8 -3
View File
@@ -2860,7 +2860,7 @@ async fn skip_unreachable_phases(pool: &PgPool) -> Result<(), String> {
}
async fn close_finished_missions(pool: &PgPool) -> Result<(), String> {
sqlx::query(
let closed: Vec<Uuid> = sqlx::query_scalar(
"UPDATE missions m
SET status =
CASE
@@ -2899,11 +2899,16 @@ async fn close_finished_missions(pool: &PgPool) -> Result<(), String> {
AND a.phase_id = mp.id
AND a.kind = 'code_diff'
)
)",
)
RETURNING m.id",
)
.execute(pool)
.fetch_all(pool)
.await
.map_err(|e| format!("close finished missions: {e}"))?;
// A closed mission's credentials end with it.
for mission_id in closed {
crate::mission_orchestrator::revoke_mission_credentials(pool, mission_id).await;
}
Ok(())
}