feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s

A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.

`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.

Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.

`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
This commit is contained in:
Daniele
2026-08-10 12:16:33 +01:00
parent 55dcb48299
commit 5980bdb5b9
7 changed files with 219 additions and 17 deletions
+20 -1
View File
@@ -31,7 +31,10 @@ use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tas
use runtime::Runtime;
use user_context::{UserContextFactory, UserContextRegistry};
pub use user_context::UserContext;
use wiring::{spawn_background, spawn_skills_freshness, spawn_system_agents, spawn_user_lifecycle, wire};
use wiring::{
spawn_background, spawn_skills_freshness, spawn_system_agents, spawn_unlocked_user_runtimes,
spawn_user_lifecycle, wire,
};
pub struct Skald {
rt: Runtime,
@@ -109,6 +112,16 @@ impl Skald {
// won't start is logged, not fatal.
container.reconcile_all().await?;
// Unlock the databases that have no key to wait for (§9). A login is what
// makes an *encrypted* file readable; for an unencrypted one it only ever
// gated the runtime — which is why, before this, a restart left Telegram,
// cron and the background agents dead until somebody opened the web UI.
// Sessions are unaffected: authentication lives above `UserManager`.
let unlocked = rt.users.unlock_all_unencrypted().await;
if unlocked > 0 {
crate::boot::section(format!("Unencrypted user databases unlocked ({unlocked})"));
}
let skald = Arc::new(Skald {
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
container,
@@ -139,6 +152,12 @@ impl Skald {
// `SkillsChanged` through `Skald`'s own accessor, same Weak shape (§8.3).
spawn_skills_freshness(&skald);
// Finally, start the runtimes of the databases unlocked above: cron, the
// notify queue and the channel plugins all hang off a `UserContext`, so an
// unencrypted member is only *working* once theirs exists. In the
// background — a build starts their per-user MCP servers.
spawn_unlocked_user_runtimes(&skald);
Ok(skald)
}
+85
View File
@@ -139,6 +139,9 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
warn!(user = %user_id, error = %e,
"user-lifecycle: failed to provision container (retried at next boot)");
}
// After the container, never before: the runtime snapshots the
// user's fs and starts their per-user MCP servers inside it.
start_runtime_if_unencrypted(&skald, &user_id).await;
}
SystemEvent::UserDeleted { user_id } => {
if let Err(e) = skald.container().remove(&user_id).await {
@@ -159,6 +162,9 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
warn!(user = %user_id, active, error = %e,
"user-lifecycle: failed to apply active-state change to container");
}
if active {
start_runtime_if_unencrypted(&skald, &user_id).await;
}
}
SystemEvent::UserMountsChanged { user_id } => {
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
@@ -182,6 +188,85 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
});
}
/// Gives a user who has just appeared (created, or reactivated) the same
/// treatment boot gives everyone: if their database has no key, unlock it and
/// start their runtime now rather than at their first login (see
/// [`spawn_unlocked_user_runtimes`]).
///
/// Reconciliation, hence the bus: a lost event costs a user whose channels and
/// cron stay asleep until the next restart or login, never a wrong grant — this
/// can only open a file that is already readable by this process, and never
/// touches authentication. An encrypted or inactive user is refused inside the
/// manager, so the outcome is a debug line, not a failure.
async fn start_runtime_if_unencrypted(skald: &Arc<super::Skald>, user_id: &str) {
if let Err(e) = skald.users().unlock_unencrypted(user_id).await {
tracing::debug!(user = %user_id, reason = %e, "user-lifecycle: database not auto-unlocked");
return;
}
if skald.user_context(user_id).await.is_none() {
warn!(user = %user_id, "user-lifecycle: could not start runtime (retried at next boot/login)");
}
}
/// Builds the per-user runtime of every database boot unlocked, so an instance
/// whose members are unencrypted comes up **working** rather than merely
/// unlocked.
///
/// Unlocking a pool only makes the data readable; what actually runs a person's
/// scheduled jobs, delivers their notifications and feeds their channel plugins
/// is their [`UserContext`](super::UserContext) — cron loop, hub, notify queue
/// and per-user MCP runtime all live there, and it is built lazily on first use.
/// Left lazy, a restart meant a cron job fired only once somebody had opened the
/// web UI, which for an unattended box is indistinguishable from it not firing.
///
/// **Background, not part of `Skald::new`**: a build starts that user's MCP
/// servers inside their container, so doing this inline would hold the HTTP
/// listener behind every member's connector startup. Sequential for the same
/// reason `reconcile_all` is — these are docker operations, and the registry
/// serialises builds anyway.
///
/// Encrypted users are absent by construction: they hold no unlocked pool, so
/// their runtime is still built by their login, as §9 requires.
pub(super) fn spawn_unlocked_user_runtimes(skald: &Arc<super::Skald>) {
let weak = Arc::downgrade(skald);
let shutdown = skald.rt.shutdown_token.clone();
skald.rt.supervisor.spawn("user-runtimes-boot", async move {
let users = {
let Some(skald) = weak.upgrade() else { return };
match skald.users().list().await {
Ok(u) => u,
Err(e) => {
warn!(error = %e, "boot: could not list users to start their runtimes");
return;
}
}
};
let mut started = 0usize;
for user in users.iter().filter(|u| u.active) {
if shutdown.is_cancelled() {
return;
}
// Whatever boot unlocked — never a decision re-derived from the row,
// so this cannot widen past what `unlock_all_unencrypted` allowed.
let Some(skald) = weak.upgrade() else { return };
if !skald.users().is_unlocked(&user.id) {
continue;
}
match skald.user_context(&user.id).await {
Some(_) => started += 1,
None => warn!(user = %user.id,
"boot: failed to start user runtime (retried at their next login)"),
}
}
if started > 0 {
info!(started, "boot: per-user runtimes started without a login");
}
});
}
/// Spawns the **skills-freshness reactor** — the subscriber that turns a
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
/// §8.3).
+99 -6
View File
@@ -189,7 +189,13 @@ impl UserManager {
if let Some(pool) = self.pool_of(id) {
return Ok(pool);
}
self.open_unencrypted_file(id).await
}
/// The row checks plus the file open shared by [`Self::open_unencrypted`] and
/// [`Self::unlock_unencrypted`]. Never consults the unlock map — the two
/// callers differ precisely in what they do with the result.
async fn open_unencrypted_file(&self, id: &str) -> Result<SqlitePool, AuthError> {
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
@@ -210,6 +216,57 @@ impl UserManager {
db::open_user_pool(&path, None).await.map_err(AuthError::Internal)
}
/// Unlocks an **unencrypted** user's database without a login, registering the
/// pool exactly as [`Self::open_db`] would.
///
/// §9 ties a database's readability to a login, and for an encrypted user that
/// is the whole point: the key only exists once the password has been typed.
/// For a user whose file has no key it is a rule with nothing behind it — the
/// data is already readable by anything in this process — while the cost is
/// real and user-visible: their Telegram chat, their cron jobs and every
/// background agent stayed dead after a restart until somebody opened the web
/// UI and logged in. So an unencrypted user's *runtime* does not wait for a
/// login; their *session* (tokens, HTTP, the web UI) still does, and that is
/// unaffected by this — `SessionStore` is a separate layer above.
///
/// Unlike [`Self::open_unencrypted`], the pool goes into the unlock map, so
/// the user counts as unlocked to everything that iterates it (system agents,
/// the channel plugins' forwarders). That is the intent, not a side effect.
///
/// Refuses an encrypted or inactive user, and is idempotent on an
/// already-unlocked one.
pub async fn unlock_unencrypted(&self, id: &str) -> Result<SqlitePool, AuthError> {
if let Some(pool) = self.pool_of(id) {
return Ok(pool);
}
let pool = self.open_unencrypted_file(id).await?;
Ok(self.register_unlocked(id, pool, false).await)
}
/// Boot pass: unlock every active unencrypted user. Returns how many pools are
/// open as a result (already-unlocked ones included).
///
/// Best-effort per user — one unreadable file must not stop the instance from
/// coming up, and the failure is the same one a login would report.
pub async fn unlock_all_unencrypted(&self) -> usize {
let users = match self.list().await {
Ok(u) => u,
Err(e) => {
warn!(error = %e, "could not list users to unlock unencrypted databases");
return 0;
}
};
let mut opened = 0usize;
for user in users.iter().filter(|u| u.active && !u.encrypted) {
match self.unlock_unencrypted(&user.id).await {
Ok(_) => opened += 1,
Err(e) => warn!(user = %user.id, error = %e, "failed to unlock unencrypted database"),
}
}
opened
}
/// Login and unlock in one operation.
///
/// For an encrypted user a single Argon2id pass answers both questions: the
@@ -250,9 +307,14 @@ impl UserManager {
.await
.map_err(AuthError::Internal)?;
// Another task may have unlocked the same user while we were deriving.
// Whoever landed first wins; ours is closed below, outside the lock,
// since `close()` is async.
Ok(self.register_unlocked(id, pool, user.is_encrypted()).await)
}
/// Puts a freshly opened pool in the unlock map, resolving the race with
/// another task that unlocked the same user while this one was opening.
/// Whoever landed first wins; the loser is closed here, outside the lock,
/// since `close()` is async.
async fn register_unlocked(&self, id: &str, pool: SqlitePool, encrypted: bool) -> SqlitePool {
let winner = {
let mut map = self.unlocked.write().expect("unlocked map poisoned");
match map.entry(id.to_string()) {
@@ -267,11 +329,11 @@ impl UserManager {
match winner {
Some(winner) => {
pool.close().await;
Ok(winner)
winner
}
None => {
info!(user = %id, encrypted = user.is_encrypted(), "user database unlocked");
Ok(pool)
info!(user = %id, encrypted, "user database unlocked");
pool
}
}
}
@@ -762,6 +824,37 @@ mod tests {
}
}
/// A login is what makes an *encrypted* file readable; for an unencrypted one
/// it gates the session, never the data. So boot unlocks the second kind and
/// leaves the first alone — the difference is the whole point of the pass.
#[tokio::test]
async fn boot_unlocks_unencrypted_users_only() {
let f = Fixture::new("bootunlock").await;
let pinned = f.users.register_user("kid", None, "children", Some("pin"), false).await.unwrap();
let open = f.users.register_user("kiosk", None, "children", None, false).await.unwrap();
let sealed = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let retired = f.users.register_user("bob", None, "children", None, false).await.unwrap();
db::users::set_active(f.users.system(), &retired, false).await.unwrap();
assert_eq!(f.users.unlock_all_unencrypted().await, 2);
// A password on an unencrypted user protects the login, not the file.
assert!(f.users.is_unlocked(&pinned), "a verifier is not a key");
assert!(f.users.is_unlocked(&open));
assert!(!f.users.is_unlocked(&sealed), "there is no key to be had without the password");
assert!(!f.users.is_unlocked(&retired), "an inactive user gets no runtime");
// The pool is a real one, and idempotent with the login path.
let pool = f.users.pool_of(&pinned).unwrap();
write_marker(&pool, "homework").await;
assert_eq!(read_marker(&f.users.open_db(&pinned, Some("pin")).await.unwrap()).await, "homework");
assert!(matches!(
f.users.unlock_unencrypted(&sealed).await.unwrap_err(),
AuthError::PasswordRequired
));
}
#[tokio::test]
async fn lock_all_drops_every_key() {
let f = Fixture::new("lockall").await;