auth: make deactivation and group revocation actually revoke
Nightly Build / build (push) Successful in 6m58s
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:
@@ -96,6 +96,11 @@ pub async fn update(
|
||||
return Err(ApiError::not_found("role not found"));
|
||||
}
|
||||
let role = roles::get(skald.db(), &id).await?.ok_or_else(|| ApiError::not_found("role not found after update"))?;
|
||||
// The edit may have narrowed the role's group set. Push that onto the members who
|
||||
// are logged in right now — their open sessions hold the previously-selected group
|
||||
// in RAM and would otherwise keep using it. (`delete` needs no equivalent: it
|
||||
// refuses while any user is still assigned.)
|
||||
skald.revalidate_security_groups_for_role(&id).await;
|
||||
Ok(Json(role))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, roles, sources};
|
||||
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
|
||||
use skald_core::db::chat_sessions_stack::SessionStack;
|
||||
use skald_core::run_context::RunContext;
|
||||
use std::sync::Arc;
|
||||
@@ -54,17 +54,16 @@ pub async fn create(
|
||||
|
||||
/// The default security-group a new session gets from the owner's role, or `None`
|
||||
/// when the role points at the catch-all `default` group (nothing to pin).
|
||||
///
|
||||
/// Thin wrapper over the core seam, which is also what
|
||||
/// [`reconcile_group_for_user`](skald_core::run_context::reconcile_group_for_user)
|
||||
/// degrades *to* — so the group a session starts on and the group it falls back to
|
||||
/// after a revocation can never drift apart.
|
||||
async fn role_default_run_context(
|
||||
skald: &Skald,
|
||||
user_id: &str,
|
||||
) -> Result<Option<RunContext>, ApiError> {
|
||||
let Some(user) = skald.users().get(user_id).await? else { return Ok(None) };
|
||||
let Some(role) = roles::get(skald.db(), &user.role_id).await? else { return Ok(None) };
|
||||
let group = role.permission_group;
|
||||
if group.is_empty() || group == "default" {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(RunContext::with_security_group(Some(group))))
|
||||
Ok(skald_core::run_context::role_default_run_context(skald.db(), user_id).await)
|
||||
}
|
||||
|
||||
// ── GET /api/web/messages ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -142,6 +142,14 @@ pub async fn update(
|
||||
body.sex.as_deref(),
|
||||
body.notes.as_deref(),
|
||||
)?;
|
||||
// Read the current row first: only a real transition should revoke a runtime,
|
||||
// touch a container or re-check permissions, so an ordinary profile edit stays free.
|
||||
let before = skald
|
||||
.users()
|
||||
.get(&id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("no such user"))?;
|
||||
let (was_active, was_role) = (before.active, before.role_id);
|
||||
skald_core::db::users::update_profile(
|
||||
skald.db(),
|
||||
&id,
|
||||
@@ -164,6 +172,26 @@ pub async fn update(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A move to a different role can narrow what this user may select. Their open
|
||||
// sessions hold the old group in RAM and the row holds it on disk, so re-check
|
||||
// both before responding — same reason deactivation is synchronous.
|
||||
if was_role != body.role_id {
|
||||
skald.revalidate_security_groups_for_user(&id).await;
|
||||
}
|
||||
|
||||
if was_active != body.active {
|
||||
// Deactivation has to bite now, not at the user's next request: revoke their
|
||||
// sessions, stop their loops and drop their database key before responding.
|
||||
if !body.active {
|
||||
skald.revoke_user_runtime(&id).await;
|
||||
}
|
||||
// The container half is reconciliation — it rides the bus.
|
||||
skald.system_bus().send(SystemEvent::UserActiveChanged {
|
||||
user_id: id.clone(),
|
||||
active: body.active,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -173,6 +201,9 @@ pub async fn delete(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
// Revoke *before* the row goes: a token outliving its user would otherwise
|
||||
// authenticate as an id that no longer exists.
|
||||
skald.revoke_user_runtime(&id).await;
|
||||
skald.users().delete_user(&id).await?;
|
||||
// The row is gone; the reconciler tears the container down (a missing one is fine).
|
||||
skald.system_bus().send(SystemEvent::UserDeleted { user_id: id });
|
||||
|
||||
Reference in New Issue
Block a user