feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s
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:
@@ -32,9 +32,10 @@ const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Periodically (re)spawns forwarders for bound + unlocked users.
|
||||
///
|
||||
/// This is load-bearing, not a nicety: at boot every pool is locked (§9), so the
|
||||
/// eager start-time pass spawns nothing. Users unlock later via web/phone login,
|
||||
/// and there is no "user unlocked" system event to hook. Without this loop a user
|
||||
/// This is load-bearing, not a nicety: an encrypted pool is locked at boot (§9),
|
||||
/// so the eager start-time pass skips those users. They unlock later via
|
||||
/// web/phone login, and there is no "user unlocked" system event to hook (an
|
||||
/// unencrypted one is already unlocked by then). Without this loop a user
|
||||
/// whose phone stays backgrounded would never get a forwarder — so no Inbox push
|
||||
/// would ever be armed for them. `ensure_forwarder` dedups, so this is idempotent
|
||||
/// and cheap (locked users resolve to `None` and are skipped without a build).
|
||||
|
||||
@@ -169,9 +169,9 @@ impl MobileConnectorPlugin {
|
||||
}
|
||||
|
||||
// Reconcile loop: (re)spawns forwarders for bound users as they unlock. Its
|
||||
// first tick fires immediately (covering already-unlocked users at start),
|
||||
// then it periodically catches users who log in later — there is no "user
|
||||
// unlocked" event to hook, and at boot every pool is locked (§9).
|
||||
// first tick fires immediately (covering the unencrypted users, unlocked at
|
||||
// boot), then it periodically catches encrypted ones as they log in — there
|
||||
// is no "user unlocked" event to hook (§9).
|
||||
{
|
||||
let app4 = Arc::clone(&app);
|
||||
handles.push(tokio::spawn(events::reconcile_loop(app4)));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user