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:
@@ -28,12 +28,14 @@ Three global broadcast buses — **never add a fourth without checking these fir
|
||||
| Bus | Cap | Events | File |
|
||||
|-----|-----|--------|------|
|
||||
| `ChatEventBus` | 256 | user message, assistant response, compaction done | `core-api/src/bus.rs` |
|
||||
| `SystemEventBus` | 64 | provider (un)registered, config key updated, job completed, session cancelled, **user created/deleted/mounts-changed** | `core-api/src/system_bus.rs` |
|
||||
| `SystemEventBus` | 64 | provider (un)registered, config key updated, job completed, session cancelled, **user created/deleted/active-changed/mounts-changed** | `core-api/src/system_bus.rs` |
|
||||
| `GlobalEvent` (per-user) | 512 | all `ServerEvent` variants → WS clients + inbox lifecycle | `core-api/src/events.rs` |
|
||||
|
||||
Plus internal `mpsc` queues: per-source `SourceInbox` (message serialization) and a central `notify` queue (background agents → user).
|
||||
|
||||
**The user-lifecycle reconciler** is the worked example of the rule. Creating a user, deleting one, or changing a shared-folder/project membership all need Docker work (provision, tear down, recreate with new bind mounts). None of the endpoints that make those changes touches `ContainerManager`: each announces `SystemEvent::User{Created,Deleted,MountsChanged}` **after** its DB write, and one subscriber — `skald::wiring::spawn_user_lifecycle`, spawned post-construction because it reacts through `Skald`'s own accessors, holding only a `Weak` — does the reacting, sequentially and best-effort. The payoff is that a *future* endpoint granting membership cannot forget to remount, because remounting was never its job. Reactions never block the HTTP response, and a failure settles at the user's next login or at boot reconciliation.
|
||||
**The user-lifecycle reconciler** is the worked example of the rule. Creating a user, deleting one, deactivating one, or changing a shared-folder/project membership all need Docker work (provision, tear down, stop, recreate with new bind mounts). None of the endpoints that make those changes touches `ContainerManager`: each announces `SystemEvent::User{Created,Deleted,ActiveChanged,MountsChanged}` **after** its DB write, and one subscriber — `skald::wiring::spawn_user_lifecycle`, spawned post-construction because it reacts through `Skald`'s own accessors, holding only a `Weak` — does the reacting, sequentially and best-effort. The payoff is that a *future* endpoint granting membership cannot forget to remount, because remounting was never its job. Reactions never block the HTTP response, and a failure settles at the user's next login or at boot reconciliation.
|
||||
|
||||
**Where the bus stops: reconciliation rides it, authorization does not.** `SystemEventBus` is a lossy 64-slot broadcast whose contract is *"best-effort, settles at the next login"* — right for a stale mount, wrong for a revocation, where "settles later" *is* the failure. So deactivating or deleting a user splits in two: `Skald::revoke_user_runtime` runs **synchronously in the handler, before it responds** (revoke every session → evict + cancel the `UserContext` → `UserManager::lock`, in that order, so nothing is left querying a pool we then close and the DEK leaves RAM per §9), while only the container half — stop or remove — rides the bus. Before this, `active = 0` blocked the *next* login but left live sessions working: `login` checks the flag, `require_auth` only maps token → id. **Never put an access revocation on a bus.**
|
||||
|
||||
**Before you add a direct function call or a new import between two components, stop and ask:** is one component producing data another needs? If yes, add a variant to an existing bus and spawn a subscriber. Don't call `some_manager.log_thing(...)` from the producer — emit a `ThingHappened` event on `SystemEventBus` and let the manager subscribe.
|
||||
|
||||
@@ -55,7 +57,7 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni
|
||||
|
||||
### Current state
|
||||
|
||||
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/`: `SessionStore` + the `guard.rs` deny-by-default middleware; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`, `TicManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
|
||||
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/mod.rs`: `SessionStore` — `login`/`user_of`/`logout` plus `revoke_user`, the admin-side "drop every session of this user" used by `Skald::revoke_user_runtime`; the deny-by-default middleware is `src/frontend/api/guard.rs`, whose `require_auth` maps token → id and does **not** re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`, and carrying its **own `CancellationToken`** (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`, `TicManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
|
||||
|
||||
Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree.
|
||||
|
||||
@@ -347,7 +349,11 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
|
||||
|
||||
**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`.
|
||||
|
||||
**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`) + a **default-assistant** select (→ `attrs.chat_agent`) fed by `GET /api/agents` filtered to `type:chat` minus `project-coordinator` (source-driven); the same exclusion is enforced server-side in the roles API (`validate_chat_agent`).
|
||||
**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged.
|
||||
|
||||
**Selection is gated once; the persisted group is re-checked on every load.** `validate_run_context_for_role` runs at *selection* time, and the result is persisted on `chat_sessions.run_context` — so on its own it let a group survive the role that granted it, indefinitely and across restarts (revoke `ops` from a role, and every session that had already picked it kept running on it). The fix is a second, narrower seam: `run_context::reconcile_group_for_user`, run by `ChatSessionManager::get_or_create_handler` on **every** handler build, which treats the stored group as *advisory* and degrades it when the owner's current role no longer allows it. Three properties are load-bearing: (a) it degrades to the **role's default group** (`role_default_group`, the same seam `sessions.rs` uses for a new session, so start-group and fallback-group cannot drift) — **never to `None`**, because a missing group means the catch-all `default`, whose rules are the fallback tier under every other group, so clearing *widens*; (b) it touches **only** `security_group`, unlike the selection path, so a project session's server-built `project_root`/`system_prompt` survive a permissions edit; (c) on uncertainty (unknown user, unreadable role, DB error) it leaves the stored group alone — guessing could only widen. The liveness half is `Skald::revalidate_security_groups_for_{user,role}`, called **synchronously** from the roles API (`update`) and the users API (role reassignment), which reconciles already-open handlers, persists, and emits `SecurityGroupSelected` so the pill re-syncs. Same rule as revocation: authorization is pushed, never left to the bus.
|
||||
|
||||
The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`) + a **default-assistant** select (→ `attrs.chat_agent`) fed by `GET /api/agents` filtered to `type:chat` minus `project-coordinator` (source-driven); the same exclusion is enforced server-side in the roles API (`validate_chat_agent`).
|
||||
|
||||
| File | Element | Notes |
|
||||
| ---- | ------- | ----- |
|
||||
|
||||
@@ -68,6 +68,18 @@ pub enum SystemEvent {
|
||||
UserDeleted {
|
||||
user_id: String,
|
||||
},
|
||||
/// A user was deactivated (`false`) or reactivated (`true`). Their sandbox is
|
||||
/// stopped or started to match — boot reconciliation keeps a container only for
|
||||
/// *active* users, so this is the running-server equivalent.
|
||||
///
|
||||
/// Revoking the live runtime (sessions, loops, database key) is **not** on this
|
||||
/// event: it is an authorization invariant and runs synchronously in the handler
|
||||
/// (`Skald::revoke_user_runtime`), because a lossy broadcast is the wrong
|
||||
/// transport for "this person must stop being logged in".
|
||||
UserActiveChanged {
|
||||
user_id: String,
|
||||
active: bool,
|
||||
},
|
||||
/// A user's **mount topology** changed — a shared-folder or project membership
|
||||
/// was granted, revoked or re-graded (RO ⇄ RW). Their container must be
|
||||
/// recreated against the new mount set, and a live session's filesystem view
|
||||
|
||||
@@ -71,6 +71,39 @@ impl SessionStore {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Removes **every** session of one user, returning how many were dropped.
|
||||
///
|
||||
/// The admin half of [`logout`](Self::logout): a deactivated or deleted user
|
||||
/// must stop being authenticated *now*, not at their next request. `login`
|
||||
/// already refuses an inactive user (`verify_credentials` / `open_db` check the
|
||||
/// flag), but `require_auth` only maps token → id and would happily keep serving
|
||||
/// a token minted before the flag flipped.
|
||||
///
|
||||
/// Sessions only, deliberately: this leaves the pool open, so the caller must
|
||||
/// follow with `UserManager::lock` to get the key out of RAM (§9). Both run
|
||||
/// synchronously on the admin's request — revocation is an invariant, not
|
||||
/// something to reconcile later on a lossy bus.
|
||||
pub fn revoke_user(&self, user_id: &str) -> usize {
|
||||
let mut map = self.sessions.write().expect("sessions map poisoned");
|
||||
let before = map.len();
|
||||
map.retain(|_, id| id != user_id);
|
||||
let removed = before - map.len();
|
||||
if removed > 0 {
|
||||
info!(user = %user_id, sessions = removed, "sessions revoked");
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Records a session without authenticating — tests only, so the revocation
|
||||
/// semantics can be exercised without paying an Argon2id derivation per login.
|
||||
#[cfg(test)]
|
||||
fn insert_session(&self, token: &str, user_id: &str) {
|
||||
self.sessions
|
||||
.write()
|
||||
.expect("sessions map poisoned")
|
||||
.insert(token.to_string(), user_id.to_string());
|
||||
}
|
||||
|
||||
/// Removes a single session. The database pool stays open (§9).
|
||||
pub fn logout(&self, token: &str) {
|
||||
if let Some(user_id) = self
|
||||
@@ -83,3 +116,59 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(tag: &str) -> (SessionStore, String) {
|
||||
let path = temp_db_path(tag);
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
(SessionStore::new(Arc::new(UserManager::new(pool))), path)
|
||||
}
|
||||
|
||||
/// Deactivating or deleting a user must drop **every** session they hold — one
|
||||
/// browser left logged in is the whole bug — and **only** theirs.
|
||||
#[tokio::test]
|
||||
async fn revoke_user_drops_all_of_one_users_sessions_and_nobody_elses() {
|
||||
let (store, path) = store("revoke").await;
|
||||
store.insert_session("t-laptop", "u-1");
|
||||
store.insert_session("t-phone", "u-1");
|
||||
store.insert_session("t-other", "u-2");
|
||||
|
||||
assert_eq!(store.revoke_user("u-1"), 2);
|
||||
assert_eq!(store.user_of("t-laptop"), None);
|
||||
assert_eq!(store.user_of("t-phone"), None);
|
||||
assert_eq!(store.user_of("t-other").as_deref(), Some("u-2"),
|
||||
"revoking one user must not log out the rest of the household");
|
||||
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// Idempotent: revoking a user with nothing live is a no-op, not an error — the
|
||||
/// admin path calls it unconditionally.
|
||||
#[tokio::test]
|
||||
async fn revoke_user_is_a_no_op_when_nothing_is_live() {
|
||||
let (store, path) = store("revoke-empty").await;
|
||||
store.insert_session("t-other", "u-2");
|
||||
|
||||
assert_eq!(store.revoke_user("u-1"), 0);
|
||||
assert_eq!(store.user_of("t-other").as_deref(), Some("u-2"));
|
||||
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,88 @@ pub async fn validate_run_context_for_role(
|
||||
}
|
||||
}
|
||||
|
||||
/// The catch-all permission group. A `RunContext` with no `security_group` resolves
|
||||
/// here, and its rules are the fallback tier under *every* other group — so clearing
|
||||
/// a group **widens** what a session may do, and is never the safe direction.
|
||||
pub const DEFAULT_GROUP_ID: &str = "default";
|
||||
|
||||
/// The security group a session gets from its owner's role: `roles.permission_group`,
|
||||
/// or `None` when that is unset or already the catch-all (nothing to pin).
|
||||
pub async fn role_default_group(registry_pool: &SqlitePool, user_id: &str) -> Option<String> {
|
||||
let user = crate::db::users::get(registry_pool, user_id).await.ok()??;
|
||||
let role = crate::db::roles::get(registry_pool, &user.role_id).await.ok()??;
|
||||
let group = role.permission_group;
|
||||
(!group.is_empty() && group != DEFAULT_GROUP_ID).then_some(group)
|
||||
}
|
||||
|
||||
/// A run-context for a **new** session carrying nothing but the role's default group,
|
||||
/// so a restricted role starts scoped instead of on the catch-all.
|
||||
pub async fn role_default_run_context(
|
||||
registry_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> Option<RunContext> {
|
||||
role_default_group(registry_pool, user_id)
|
||||
.await
|
||||
.map(|g| RunContext::with_security_group(Some(g)))
|
||||
}
|
||||
|
||||
/// Re-checks a **persisted** run-context's security group against the owner's
|
||||
/// *current* role, degrading it to the role default when the role no longer allows it.
|
||||
///
|
||||
/// This is the counterpart of [`validate_run_context_for_role`], which gates a group
|
||||
/// at *selection* time. The selected group is then persisted on `chat_sessions.
|
||||
/// run_context` and was replayed verbatim on every later load — so revoking a group
|
||||
/// from a role, or moving a user to a stricter role, left every session that already
|
||||
/// had it running with it, indefinitely and across restarts. Running every load
|
||||
/// through here makes the persisted value advisory rather than authoritative.
|
||||
///
|
||||
/// Deliberately **narrow**: only `security_group` is touched. A project session's
|
||||
/// server-built context (`project_root`, `system_prompt`, fs grants) must survive
|
||||
/// intact — unlike the selection path, which discards those because they came from
|
||||
/// a client.
|
||||
///
|
||||
/// Conservative on uncertainty: with no group, an `admin` owner, or a role that
|
||||
/// cannot be read, the context is returned unchanged. Guessing on a transient DB
|
||||
/// error could only widen the session, which is the one outcome worth avoiding.
|
||||
pub async fn reconcile_group_for_user(
|
||||
registry_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
rc: Option<RunContext>,
|
||||
) -> Option<RunContext> {
|
||||
let mut rc = rc?;
|
||||
let Some(group) = rc.tool_group_id().map(str::to_string) else { return Some(rc) };
|
||||
|
||||
let role_id = match crate::db::users::get(registry_pool, user_id).await {
|
||||
Ok(Some(u)) => u.role_id,
|
||||
other => {
|
||||
tracing::warn!(user = %user_id, group = %group, missing = other.is_ok(),
|
||||
"run_context: cannot resolve role, leaving the persisted security group in place");
|
||||
return Some(rc);
|
||||
}
|
||||
};
|
||||
if role_id == crate::db::roles::ADMIN_ROLE_ID {
|
||||
return Some(rc);
|
||||
}
|
||||
match crate::db::roles::role_allows_group(registry_pool, &role_id, &group).await {
|
||||
Ok(true) => return Some(rc),
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(user = %user_id, group = %group, error = %e,
|
||||
"run_context: group check failed, leaving the persisted security group in place");
|
||||
return Some(rc);
|
||||
}
|
||||
}
|
||||
|
||||
let replacement = role_default_group(registry_pool, user_id).await;
|
||||
info!(
|
||||
user = %user_id, role = %role_id, revoked = %group,
|
||||
now = replacement.as_deref().unwrap_or(DEFAULT_GROUP_ID),
|
||||
"run_context: security group no longer allowed by the role, degraded to the role default"
|
||||
);
|
||||
rc.security_group = replacement;
|
||||
Some(rc)
|
||||
}
|
||||
|
||||
pub struct RunContextManager {
|
||||
db: Arc<SqlitePool>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
@@ -370,6 +452,100 @@ mod tests {
|
||||
std::fs::remove_dir_all(&wd).ok();
|
||||
}
|
||||
|
||||
/// A registry with one role and one member of it. No crypto: the reconcile path
|
||||
/// only reads the directory, never a credential.
|
||||
async fn registry_with(role: &str, default_group: &str, extra_groups: &str) -> SqlitePool {
|
||||
let path = unique_tmp().join("system.db");
|
||||
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
crate::db::roles::insert(&pool, role, "Role", default_group, Some(extra_groups))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::users::insert(
|
||||
&pool, "u-1", "ada", None, role,
|
||||
&crate::db::users::Credentials::Cleartext(None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
/// The regression: a group selected while the role allowed it was replayed from
|
||||
/// `chat_sessions.run_context` forever, so revoking it from the role changed
|
||||
/// nothing for sessions that already had it.
|
||||
#[tokio::test]
|
||||
async fn reconcile_degrades_a_group_the_role_no_longer_allows() {
|
||||
// The role's default is `kid`; `ops` is NOT in its set (it was, once).
|
||||
let pool = registry_with("member", "kid", r#"{"permission_groups":[]}"#).await;
|
||||
|
||||
let rc = RunContext { security_group: Some("ops".into()), ..Default::default() };
|
||||
let got = reconcile_group_for_user(&pool, "u-1", Some(rc)).await.unwrap();
|
||||
assert_eq!(got.tool_group_id(), Some("kid"),
|
||||
"a revoked group must degrade to the role default, never to the catch-all");
|
||||
}
|
||||
|
||||
/// Degrading must not silently widen: clearing to `None` would put a restricted
|
||||
/// user on the catch-all `default` group, whose rules are the fallback tier under
|
||||
/// every other group.
|
||||
#[tokio::test]
|
||||
async fn reconcile_keeps_an_allowed_group_and_preserves_the_rest_of_the_context() {
|
||||
let pool = registry_with("member", "kid", r#"{"permission_groups":["ops"]}"#).await;
|
||||
|
||||
// Still allowed → untouched, including a project session's server-built fields,
|
||||
// which the *selection* path would have stripped.
|
||||
let rc = RunContext {
|
||||
security_group: Some("ops".into()),
|
||||
project_root: Some("projects/ada/site".into()),
|
||||
system_prompt: vec!["project brief".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let got = reconcile_group_for_user(&pool, "u-1", Some(rc)).await.unwrap();
|
||||
assert_eq!(got.tool_group_id(), Some("ops"));
|
||||
assert_eq!(got.project_root.as_deref(), Some("projects/ada/site"));
|
||||
assert_eq!(got.system_prompt, vec!["project brief".to_string()]);
|
||||
}
|
||||
|
||||
/// A degrade must keep the rest of the context too — losing `project_root` would
|
||||
/// break a project chat as a side effect of a permissions edit.
|
||||
#[tokio::test]
|
||||
async fn reconcile_degrade_preserves_project_fields() {
|
||||
let pool = registry_with("member", "kid", r#"{"permission_groups":[]}"#).await;
|
||||
|
||||
let rc = RunContext {
|
||||
security_group: Some("ops".into()),
|
||||
project_root: Some("projects/ada/site".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let got = reconcile_group_for_user(&pool, "u-1", Some(rc)).await.unwrap();
|
||||
assert_eq!(got.tool_group_id(), Some("kid"));
|
||||
assert_eq!(got.project_root.as_deref(), Some("projects/ada/site"));
|
||||
}
|
||||
|
||||
/// Uncertainty must never widen: an unknown user leaves the persisted group alone
|
||||
/// rather than falling back to the catch-all.
|
||||
#[tokio::test]
|
||||
async fn reconcile_leaves_the_group_alone_when_the_role_cannot_be_resolved() {
|
||||
let pool = registry_with("member", "kid", r#"{"permission_groups":[]}"#).await;
|
||||
|
||||
let rc = RunContext { security_group: Some("ops".into()), ..Default::default() };
|
||||
let got = reconcile_group_for_user(&pool, "ghost", Some(rc)).await.unwrap();
|
||||
assert_eq!(got.tool_group_id(), Some("ops"));
|
||||
}
|
||||
|
||||
/// `admin` holds every group by construction, so nothing is ever degraded for it.
|
||||
#[tokio::test]
|
||||
async fn reconcile_never_touches_an_admin() {
|
||||
let path = unique_tmp().join("system.db");
|
||||
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
crate::db::users::insert(
|
||||
&pool, "u-admin", "root", None, crate::db::roles::ADMIN_ROLE_ID,
|
||||
&crate::db::users::Credentials::Cleartext(None),
|
||||
).await.unwrap();
|
||||
|
||||
let rc = RunContext { security_group: Some("anything".into()), ..Default::default() };
|
||||
let got = reconcile_group_for_user(&pool, "u-admin", Some(rc)).await.unwrap();
|
||||
assert_eq!(got.tool_group_id(), Some("anything"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_admin_passes_through_untouched() {
|
||||
let path = unique_tmp().join("system.db");
|
||||
|
||||
@@ -191,7 +191,16 @@ impl ChatSessionManager {
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("session {session_id} not found"))?;
|
||||
|
||||
let run_context = session.run_context.as_deref().and_then(RunContext::from_db);
|
||||
// The persisted group is **advisory**: re-check it against the owner's current
|
||||
// role, so a group revoked since the session last ran cannot be replayed from
|
||||
// the row. Every load goes through here — restart, re-login, new handler — so
|
||||
// correctness does not depend on anyone having pushed a notification.
|
||||
let run_context = crate::run_context::reconcile_group_for_user(
|
||||
&self.shared_pool,
|
||||
&self.user_id,
|
||||
session.run_context.as_deref().and_then(RunContext::from_db),
|
||||
)
|
||||
.await;
|
||||
|
||||
let handler = Arc::new(ChatSessionHandler::new(
|
||||
session_id,
|
||||
@@ -228,4 +237,49 @@ impl ChatSessionManager {
|
||||
pub fn refresh_fs(&self, fs: UserFs) {
|
||||
self.user_fs.store(fs);
|
||||
}
|
||||
|
||||
/// Re-checks every **live** handler's security group against the owner's current
|
||||
/// role, degrading any the role no longer allows (see
|
||||
/// [`crate::run_context::reconcile_group_for_user`]).
|
||||
///
|
||||
/// [`get_or_create_handler`](Self::get_or_create_handler) already reconciles on
|
||||
/// load, which covers every future session; this covers the sessions that are
|
||||
/// *already* open, whose handler holds its run-context in RAM and would otherwise
|
||||
/// keep the revoked group until the process restarts. Both the row and the live
|
||||
/// handler are updated, so the change survives and the UI reads the truth.
|
||||
///
|
||||
/// Returns `(source, effective group)` for each session that actually changed —
|
||||
/// the caller broadcasts `SecurityGroupSelected` so open tabs re-sync their pill.
|
||||
pub async fn revalidate_security_groups(&self) -> Vec<(String, String)> {
|
||||
let handlers: Vec<_> = self.active.lock().await
|
||||
.iter().map(|(id, h)| (*id, Arc::clone(h))).collect();
|
||||
|
||||
let mut changed = Vec::new();
|
||||
for (session_id, handler) in handlers {
|
||||
let before = handler.run_context.read().await.clone();
|
||||
let before_group = before.as_ref().and_then(|rc| rc.tool_group_id().map(str::to_string));
|
||||
let after = crate::run_context::reconcile_group_for_user(
|
||||
&self.shared_pool, &self.user_id, before,
|
||||
).await;
|
||||
let after_group = after.as_ref().and_then(|rc| rc.tool_group_id().map(str::to_string));
|
||||
if before_group == after_group {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = chat_sessions::set_run_context(
|
||||
&self.db, session_id, after.as_ref().map(|rc| rc.to_db()).as_deref(),
|
||||
).await {
|
||||
// The in-RAM update below still takes effect for this process; the
|
||||
// reconcile on next load would redo the degrade anyway.
|
||||
tracing::warn!(session = session_id, error = %e,
|
||||
"failed to persist a degraded security group");
|
||||
}
|
||||
handler.set_run_context(after).await;
|
||||
changed.push((
|
||||
handler.source.clone(),
|
||||
after_group.unwrap_or_else(|| crate::run_context::DEFAULT_GROUP_ID.to_string()),
|
||||
));
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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