From 2069bdf322f1f5adf5331c3aa1cbb0fd803db99c Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Sun, 20 Sep 2026 22:20:13 -0500 Subject: [PATCH] sec(auth): a mission's door token is revoked when the mission ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz --- crates/cm-api/src/mission_orchestrator.rs | 35 +++++++- crates/cm-api/src/phase_runner.rs | 11 ++- crates/cm-api/src/routes/missions.rs | 5 ++ crates/cm-auth/src/service.rs | 41 ++++++++++ crates/cm-auth/tests/mission_sessions.rs | 97 +++++++++++++++++++++++ migrations/0087_auth_sessions_mission.sql | 20 +++++ scripts/verify-mission-delivery.sh | 44 +++++++++- 7 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 crates/cm-auth/tests/mission_sessions.rs create mode 100644 migrations/0087_auth_sessions_mission.sql diff --git a/crates/cm-api/src/mission_orchestrator.rs b/crates/cm-api/src/mission_orchestrator.rs index 581d61b..2ae248e 100644 --- a/crates/cm-api/src/mission_orchestrator.rs +++ b/crates/cm-api/src/mission_orchestrator.rs @@ -955,11 +955,19 @@ async fn install_skills_door( ); return false; }; - // Outlives the longest mission we have seen, and expires on its own so a - // leaked container does not leave a live credential behind indefinitely. + // Bound to the mission: revoked by `revoke_mission_credentials` the + // moment it reaches a terminal status. The 24 h TTL is the backstop for a + // mission nothing ever closes, not the credential's lifetime — until + // 2026-09-20 it was, and a twenty-minute mission left a live token in + // its container for the other twenty-three hours. let auth = cm_auth::AuthService::new(pool.clone()); let token = match auth - .mint_scoped(user_id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(24)) + .mint_scoped_for_mission( + user_id, + cm_auth::SCOPE_SKILLS_READ, + time::Duration::hours(24), + mission_id, + ) .await { Ok(t) => t, @@ -1093,3 +1101,24 @@ async fn record_skill_delivery(pool: &PgPool, mission_id: Uuid, mode: crate::ski ); } } + +/// Revoke every credential minted for a mission. Called on every path that +/// takes a mission to a terminal status — the runner's close and the +/// operator's stop — so the authority a mission was given ends with it. +/// Best-effort and loud: a revocation that failed is logged with the count +/// it could not clear, which is the number an operator needs. +pub async fn revoke_mission_credentials(pool: &PgPool, mission_id: Uuid) { + match cm_auth::AuthService::new(pool.clone()) + .revoke_mission_sessions(mission_id) + .await + { + Ok(0) => {} + Ok(n) => eprintln!( + "mission_orchestrator: revoked {n} credential(s) for mission {mission_id} at close" + ), + Err(e) => eprintln!( + "mission_orchestrator: could NOT revoke credentials for mission {mission_id}: {e} \ + — they expire on their own within 24 h" + ), + } +} diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index 5cd7992..fab21ac 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -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 = 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(()) } diff --git a/crates/cm-api/src/routes/missions.rs b/crates/cm-api/src/routes/missions.rs index e0aa209..f41d8a5 100644 --- a/crates/cm-api/src/routes/missions.rs +++ b/crates/cm-api/src/routes/missions.rs @@ -1494,6 +1494,11 @@ pub async fn set_status( cm_db::repo::missions::set_status(&state.pool, id, user.workspace_id.as_uuid(), &body.status) .await?; + // An operator's stop is a terminal transition too, and the runner's + // close never sees it: revoke here as well. + if matches!(body.status.as_str(), "completed" | "failed" | "cancelled") { + crate::mission_orchestrator::revoke_mission_credentials(&state.pool, id).await; + } let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid()) .await? diff --git a/crates/cm-auth/src/service.rs b/crates/cm-auth/src/service.rs index d2f6304..e2fe9a5 100644 --- a/crates/cm-auth/src/service.rs +++ b/crates/cm-auth/src/service.rs @@ -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 { + 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 { + 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 diff --git a/crates/cm-auth/tests/mission_sessions.rs b/crates/cm-auth/tests/mission_sessions.rs new file mode 100644 index 0000000..b1a9ef7 --- /dev/null +++ b/crates/cm-auth/tests/mission_sessions.rs @@ -0,0 +1,97 @@ +//! A credential minted for a mission ends with the mission. +//! +//! The skills-door token is scoped and short-lived, and before 2026-09-20 it +//! was also un-revocable: nothing tied it to the mission, so a mission that +//! finished in minutes left a live token in its container for the rest of +//! the 24 h TTL. These tests pin the two halves — mint-for-mission +//! authenticates like any scoped token, and revoke-for-mission kills it. + +use cm_auth::{bootstrap_owner, AuthService, SCOPE_SKILLS_READ}; + +async fn owner(pool: &sqlx::PgPool) -> cm_domain::User { + bootstrap_owner(pool, "Acme", "owner@acme.test", "pw-123456", 0) + .await + .unwrap(); + cm_db::repo::users::find_by_email(pool, "owner@acme.test") + .await + .unwrap() +} + +async fn mission(pool: &sqlx::PgPool, ws: uuid::Uuid) -> uuid::Uuid { + let id = uuid::Uuid::now_v7(); + sqlx::query( + "INSERT INTO missions (id, workspace_id, title, template_kind, status) + VALUES ($1, $2, 'm', 'research_only', 'running')", + ) + .bind(id) + .bind(ws) + .execute(pool) + .await + .unwrap(); + id +} + +#[tokio::test] +async fn a_mission_token_works_until_the_mission_is_closed_and_not_after() { + let pool = cm_testkit::test_pool().await; + let user = owner(&pool).await; + let auth = AuthService::new(pool.clone()); + let m = mission(&pool, user.workspace_id.as_uuid()).await; + + let token = auth + .mint_scoped_for_mission( + user.id, + SCOPE_SKILLS_READ, + time::Duration::hours(24), + m, + ) + .await + .unwrap(); + // Positive control: the token is a real, scoped credential. + auth.authenticate_scoped(&token, SCOPE_SKILLS_READ) + .await + .expect("a freshly minted mission token authenticates for its scope"); + assert!( + auth.authenticate(&token).await.is_err(), + "a skills token must not pass as a full session" + ); + + // Revocation: exactly this mission's rows, and the token is dead after. + let revoked = auth.revoke_mission_sessions(m).await.unwrap(); + assert_eq!(revoked, 1); + assert!( + auth.authenticate_scoped(&token, SCOPE_SKILLS_READ).await.is_err(), + "a revoked mission token must not authenticate" + ); + assert_eq!(auth.revoke_mission_sessions(m).await.unwrap(), 0); +} + +#[tokio::test] +async fn revoking_one_mission_leaves_another_missions_token_alone() { + let pool = cm_testkit::test_pool().await; + let user = owner(&pool).await; + let auth = AuthService::new(pool.clone()); + let a = mission(&pool, user.workspace_id.as_uuid()).await; + let b = mission(&pool, user.workspace_id.as_uuid()).await; + let ta = auth + .mint_scoped_for_mission(user.id, SCOPE_SKILLS_READ, time::Duration::hours(1), a) + .await + .unwrap(); + let tb = auth + .mint_scoped_for_mission(user.id, SCOPE_SKILLS_READ, time::Duration::hours(1), b) + .await + .unwrap(); + assert_eq!(auth.revoke_mission_sessions(a).await.unwrap(), 1); + assert!(auth.authenticate_scoped(&ta, SCOPE_SKILLS_READ).await.is_err()); + auth.authenticate_scoped(&tb, SCOPE_SKILLS_READ) + .await + .expect("the other mission's token is untouched"); + + // Purging a mission takes its sessions with it (ON DELETE CASCADE). + sqlx::query("DELETE FROM missions WHERE id = $1") + .bind(b) + .execute(&pool) + .await + .unwrap(); + assert!(auth.authenticate_scoped(&tb, SCOPE_SKILLS_READ).await.is_err()); +} diff --git a/migrations/0087_auth_sessions_mission.sql b/migrations/0087_auth_sessions_mission.sql new file mode 100644 index 0000000..7843309 --- /dev/null +++ b/migrations/0087_auth_sessions_mission.sql @@ -0,0 +1,20 @@ +-- Which mission a scoped session was minted for, so it can be revoked when +-- the mission ends. +-- +-- The skills-door token is minted once per mission with a 24 h TTL — long +-- enough to outlive the longest mission — and nothing revoked it sooner. A +-- mission that finished in twenty minutes left a live credential in its +-- container for the other twenty-three hours: Lingering Authority (arXiv +-- 2606.22504) is the paper on exactly this, and its fix is a capability +-- bound to the task that ends with it. +-- +-- ON DELETE CASCADE, so purging a mission revokes its sessions with it. +ALTER TABLE auth_sessions + ADD COLUMN IF NOT EXISTS mission_id UUID REFERENCES missions (id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS auth_sessions_mission_idx + ON auth_sessions (mission_id) + WHERE mission_id IS NOT NULL; + +COMMENT ON COLUMN auth_sessions.mission_id IS + 'The mission this session was minted for; revoked when it reaches a terminal status. NULL for user and service sessions.'; diff --git a/scripts/verify-mission-delivery.sh b/scripts/verify-mission-delivery.sh index ca7f235..0fd97aa 100755 --- a/scripts/verify-mission-delivery.sh +++ b/scripts/verify-mission-delivery.sh @@ -1129,7 +1129,8 @@ GATEPOLICY_BODY=$(cat < *) fail "gatepolicy: no gate.denied with rule=hook-files (recorded: ${rules:-none})" ;; esac + # The door token this mission was given ended with the mission. + assert_credentials_revoked "$mission" gatepolicy + # The reasons reached the model. delivered=$(fetch_delivered "$token" "$mission" GATE.md 2>/dev/null || true) case "$delivered" in @@ -1169,6 +1173,44 @@ assert_gatepolicy() { # esac } +# A mission's credentials end with it. Three checks: the server said it +# revoked at least one (proof one was MINTED — a mission on the files arm +# mints none and would pass the row count trivially); no auth_sessions row +# carries this mission id; and, when the container is still there to read +# the token from, the door answers 401 to it. Lingering Authority (arXiv +# 2606.22504) is the reference: 10/10 post-closure reuse rejected. +assert_credentials_revoked() { #