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
+32 -3
View File
@@ -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"
),
}
}
+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(())
}
+5
View File
@@ -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?
+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
+97
View File
@@ -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", "[email protected]", "pw-123456", 0)
.await
.unwrap();
cm_db::repo::users::find_by_email(pool, "[email protected]")
.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());
}