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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user