auth: make deactivation and group revocation actually revoke
Nightly Build / build (push) Successful in 6m58s

Two variants of the same defect: an admin took away access and the running
system kept granting it.

Deactivating or deleting a user only stopped the *next* login. `login`
checks the active flag, but `require_auth` maps token -> id without
re-reading the row, so an already-open session kept working over a pool
whose key was still in RAM. There was no way to stop one user either: the
per-user cron, hub and MCP loops all observed the *instance* shutdown
token. They now take a per-user child token stored on UserContext, and
Skald::revoke_user_runtime tears a single user down in a load-bearing
order — revoke every session, evict and cancel the context, then lock the
database, so nothing is left querying a pool we are about to close.

Revoking a security group had a durable version of the same problem. The
group is validated when selected and then persisted on
chat_sessions.run_context, which was replayed verbatim on every later
load — so a group removed from a role stayed in force on sessions that
already had it, across restarts. get_or_create_handler now runs the stored
value through run_context::reconcile_group_for_user, making it advisory:
every load re-checks it, whether or not anyone announced the change.

The degrade target is the role's default group, never None: a context with
no group resolves to the catch-all `default`, whose rules are the fallback
tier under every other group, so clearing widens rather than narrows. The
reconcile touches only security_group, so a project session's server-built
project_root and system_prompt survive a permissions edit, and it leaves
the stored group alone when the role cannot be resolved — guessing on a
transient error could only widen. role_default_run_context moves into the
core seam so the group a session starts on and the group it falls back to
cannot drift apart.

Both fixes run synchronously in their handlers. Only the container half of
deactivation rides the bus, as the new UserActiveChanged event: a lossy
64-slot broadcast whose contract is "settles at the next login" is the
wrong transport for taking access away.

Tests: revoke_user drops all of one user's sessions and nobody else's, and
is a no-op when nothing is live; the reconcile degrades a revoked group to
the role default, keeps an allowed one, preserves project fields in both
directions, never touches an admin, and stays put when the role is
unresolvable.

Not exercised at runtime: no Docker/live-server run, so the end-to-end
paths (deactivating a logged-in user, editing a role with sessions open)
are covered by unit tests only.
This commit is contained in:
2026-07-26 22:17:30 +01:00
parent c50a0d84da
commit 0ba140186f
11 changed files with 501 additions and 19 deletions
+65
View File
@@ -70,6 +70,71 @@ impl Skald {
self.rt_user_contexts().peek(user_id).await
}
/// Revokes a user's live runtime: sessions, owner-bound loops, database key.
///
/// Called when a user is **deactivated or deleted**. Writing `active = 0` (or
/// deleting the row) only stops the *next* login: `login` checks the flag, but
/// `require_auth` maps token → id without re-reading the row, so a session minted
/// before the change would keep working, over a pool whose key is still in RAM.
///
/// The order is load-bearing:
///
/// 1. **Revoke the sessions** — the moment this returns, no token authenticates
/// as this user.
/// 2. **Evict the context** — cancels their cron loop, hub and per-user MCP
/// runtime, so nothing is left to query the pool we are about to close.
/// 3. **Lock the database** — `close()`s the pool, which invalidates every
/// surviving clone and drops the DEK (§9). The user is opaque again.
///
/// Synchronous by design: this is an authorization invariant, not reconciliation,
/// so it must not ride the lossy system bus. The Docker half (stop or remove the
/// container) *is* reconciliation and does ride it.
///
/// Idempotent — a user with no live session and a locked database is a no-op.
pub async fn revoke_user_runtime(&self, user_id: &str) {
self.sessions().revoke_user(user_id);
self.rt_user_contexts().evict(user_id).await;
self.rt.users.lock(user_id).await;
}
/// Re-checks a live user's open sessions against their current role, degrading any
/// security group the role no longer allows, and tells their open tabs about it.
///
/// The durable half of this is in `ChatSessionManager::get_or_create_handler`,
/// which reconciles on every load; this is the liveness half, for sessions already
/// in RAM. Synchronous, like [`Self::revoke_user_runtime`] and for the same reason:
/// narrowing someone's permissions is an authorization change, not reconciliation.
///
/// No-op for a user who is not logged in — their next login loads through the
/// reconcile anyway.
pub async fn revalidate_security_groups_for_user(&self, user_id: &str) {
let Some(ctx) = self.user_context_if_live(user_id).await else { return };
for (source, group) in ctx.sessions.revalidate_security_groups().await {
ctx.chat_hub.emit(core_api::events::GlobalEvent {
source: Some(source),
session_id: None,
event: core_api::events::ServerEvent::SecurityGroupSelected { group },
});
}
}
/// [`Self::revalidate_security_groups_for_user`] for every member of a role —
/// called when the role's own group set changes, which can narrow many users at
/// once. Members who are not logged in need nothing.
pub async fn revalidate_security_groups_for_role(&self, role_id: &str) {
let users = match crate::db::users::list(self.db()).await {
Ok(u) => u,
Err(e) => {
tracing::warn!(role = %role_id, error = %e,
"cannot list users to revalidate security groups");
return;
}
};
for user in users.into_iter().filter(|u| u.role_id == role_id) {
self.revalidate_security_groups_for_user(&user.id).await;
}
}
/// Applies a shared-folder membership change to a user (blueprint §6 remount).
///
/// A container's bind mounts are fixed at `docker create` time, so the mount set
+37 -6
View File
@@ -65,6 +65,11 @@ use super::runtime::Runtime;
pub struct UserContext {
pub user_id: String,
pub pool: Arc<SqlitePool>,
/// This user's stop signal — a child of the instance shutdown token. Every
/// owner-bound loop (cron, hub, per-user MCP) observes it, so cancelling it
/// tears down exactly one user's runtime without touching anyone else's.
/// Cancelled by [`UserContextRegistry::evict`] on deactivation/deletion.
pub shutdown: CancellationToken,
/// The owner's filesystem view (home + shared folders + container, §6),
/// threaded into every `ToolContext` this user's sessions produce. A shared
/// swappable cell so a shared-folder membership change is applied in place
@@ -181,6 +186,13 @@ impl UserContextFactory {
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let pool = Arc::new(pool);
// This user's own stop signal: a **child** of the instance token, so a global
// shutdown still stops every user's loops, while cancelling it alone tears
// down exactly one user's runtime (deactivation / deletion — see
// `UserContextRegistry::evict`). Every owner-bound loop below takes this
// token, never the instance one, or a revoked user's cron would keep polling
// a closed pool.
let user_shutdown = self.shutdown_token.child_token();
// The owner's filesystem view: private home + shared folders + container.
// A shared swappable cell — a shared-folder membership change is applied in
// place while the user is live (§6 remount), not deferred to next login.
@@ -227,7 +239,7 @@ impl UserContextFactory {
}
let user_mcp = Arc::new(McpManager::new(
Arc::clone(&pool),
self.shutdown_token.clone(),
user_shutdown.clone(),
"data",
));
// NOTE: per-user MCP elicitation (interactive connector login, §15) is
@@ -335,7 +347,7 @@ impl UserContextFactory {
Arc::clone(&manager),
Arc::clone(&approval),
global_tx.clone(),
self.shutdown_token.clone(),
user_shutdown.clone(),
default_agent,
);
chat_hub.register("web").await;
@@ -350,14 +362,16 @@ impl UserContextFactory {
// durable cron job (blueprint §7.2) instead of an interface-tool call.
manager.loop_runtime().set_task_manager(Arc::clone(&cron));
// Per-user cron loop. `start()` observes the shutdown token, so it stops on
// shutdown; adopting it lets the supervisor also join it. The name is leaked
// to satisfy the `&'static str` label — bounded by the (small) user count.
// Per-user cron loop. `start()` observes the token, so it stops on a global
// shutdown *or* on this user's own revocation; adopting it lets the supervisor
// also join it. The name is leaked to satisfy the `&'static str` label —
// bounded by the (small) user count.
let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str());
self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone()));
self.supervisor.adopt(name, Arc::clone(&cron).start(user_shutdown.clone()));
Ok(Arc::new(UserContext {
user_id: user_id.to_string(),
shutdown: user_shutdown,
pool,
fs,
event_bus,
@@ -413,6 +427,23 @@ impl UserContextRegistry {
pub(super) async fn all_live(&self) -> Vec<Arc<UserContext>> {
self.contexts.lock().await.values().cloned().collect()
}
/// Removes a user's context and cancels it — the runtime half of revoking a
/// user (deactivation or deletion).
///
/// Cancelling the context's own token stops its cron loop, hub and per-user MCP
/// runtime; dropping the registry's `Arc` lets the context die once the last
/// in-flight borrow releases it, at which point the `docker exec -i` children of
/// its MCP servers are reaped by `kill_on_drop`. Locking the pool is **not** done
/// here — that is `UserManager`'s job (§11 boundary) and the caller sequences it
/// after this, so no loop is left querying a closed pool.
///
/// Returns the evicted context, or `None` if the user was not live.
pub(super) async fn evict(&self, user_id: &str) -> Option<Arc<UserContext>> {
let ctx = self.contexts.lock().await.remove(user_id)?;
ctx.shutdown.cancel();
Some(ctx)
}
}
// ── UserChannelHandle impl ────────────────────────────────────────────────────
+14
View File
@@ -134,6 +134,20 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
"user-lifecycle: failed to remove container");
}
}
// Match boot reconciliation, which keeps a container only for active
// users. The user's live runtime is already gone by now — the handler
// revoked it synchronously before emitting.
SystemEvent::UserActiveChanged { user_id, active } => {
let result = if active {
skald.container().ensure(&user_id).await
} else {
skald.container().stop(&user_id).await
};
if let Err(e) = result {
warn!(user = %user_id, active, error = %e,
"user-lifecycle: failed to apply active-state change to container");
}
}
SystemEvent::UserMountsChanged { user_id } => {
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
warn!(user = %user_id, error = %e,