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:
co-authored by
Claude Opus 5
parent
3909fa14ca
commit
2069bdf322
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user