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
@@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user