Release 0.2.0 #4
@@ -59,6 +59,8 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni
|
|||||||
|
|
||||||
`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`) 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`) 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.
|
||||||
|
|
||||||
|
**Boot unlocks the databases that have no key, and starts their runtimes.** §9 ties readability to a login, and for an encrypted file that *is* the mechanism — the key only exists once the password has been typed. For an unencrypted one it was a rule with nothing behind it: the data is already readable by anything in this process, so the only thing the login gated was the runtime. The cost was user-visible and looked like 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. So `Skald::new` calls `UserManager::unlock_all_unencrypted` (which registers the pools exactly as a login would, refusing an encrypted or inactive user), and `wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for each — **unlocking only makes the data readable; cron, the notify queue, the hub and the per-user MCP runtime all hang off the context**, so an instance is *working* only once those exist. That build is a background supervisor task, not part of `new()`: it starts every member's MCP servers inside their container, and the HTTP listener must not wait behind that. 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 are untouched and worth stating: **authentication is unaffected** (`SessionStore` sits above `UserManager`; no HTTP request authenticates as anyone because of this), and `open_unencrypted` still exists for the supervision path, still deliberately *not* registering its pool. The auto-unlock is deliberately not on a lazy path (e.g. inside `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 lifecycle bus.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Workspace layout
|
## Workspace layout
|
||||||
@@ -101,7 +103,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
|
|||||||
| `crates/skald-core/src/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) |
|
| `crates/skald-core/src/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) |
|
||||||
| `crates/skald-core/src/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend |
|
| `crates/skald-core/src/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend |
|
||||||
| `crates/skald-core/src/db/` | sqlx SQLite — see below |
|
| `crates/skald-core/src/db/` | sqlx SQLite — see below |
|
||||||
| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it |
|
| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it. **A login is what unlocks an *encrypted* file only** — see the boot-unlock section below |
|
||||||
| `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) |
|
| `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) |
|
||||||
| `src/config.rs` | Loads `config.yml`; LLM clients, strength, data root. All relative paths (db, logs, data, …) resolve against the launch cwd |
|
| `src/config.rs` | Loads `config.yml`; LLM clients, strength, data root. All relative paths (db, logs, data, …) resolve against the launch cwd |
|
||||||
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
|
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
|
|||||||
|
|
||||||
/// Periodically (re)spawns forwarders for bound + unlocked users.
|
/// Periodically (re)spawns forwarders for bound + unlocked users.
|
||||||
///
|
///
|
||||||
/// This is load-bearing, not a nicety: at boot every pool is locked (§9), so the
|
/// This is load-bearing, not a nicety: an encrypted pool is locked at boot (§9),
|
||||||
/// eager start-time pass spawns nothing. Users unlock later via web/phone login,
|
/// so the eager start-time pass skips those users. They unlock later via
|
||||||
/// and there is no "user unlocked" system event to hook. Without this loop a user
|
/// 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
|
/// 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
|
/// 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).
|
/// 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
|
// Reconcile loop: (re)spawns forwarders for bound users as they unlock. Its
|
||||||
// first tick fires immediately (covering already-unlocked users at start),
|
// first tick fires immediately (covering the unencrypted users, unlocked at
|
||||||
// then it periodically catches users who log in later — there is no "user
|
// boot), then it periodically catches encrypted ones as they log in — there
|
||||||
// unlocked" event to hook, and at boot every pool is locked (§9).
|
// is no "user unlocked" event to hook (§9).
|
||||||
{
|
{
|
||||||
let app4 = Arc::clone(&app);
|
let app4 = Arc::clone(&app);
|
||||||
handles.push(tokio::spawn(events::reconcile_loop(app4)));
|
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 runtime::Runtime;
|
||||||
use user_context::{UserContextFactory, UserContextRegistry};
|
use user_context::{UserContextFactory, UserContextRegistry};
|
||||||
pub use user_context::UserContext;
|
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 {
|
pub struct Skald {
|
||||||
rt: Runtime,
|
rt: Runtime,
|
||||||
@@ -109,6 +112,16 @@ impl Skald {
|
|||||||
// won't start is logged, not fatal.
|
// won't start is logged, not fatal.
|
||||||
container.reconcile_all().await?;
|
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 {
|
let skald = Arc::new(Skald {
|
||||||
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
||||||
container,
|
container,
|
||||||
@@ -139,6 +152,12 @@ impl Skald {
|
|||||||
// `SkillsChanged` through `Skald`'s own accessor, same Weak shape (§8.3).
|
// `SkillsChanged` through `Skald`'s own accessor, same Weak shape (§8.3).
|
||||||
spawn_skills_freshness(&skald);
|
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)
|
Ok(skald)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
|
|||||||
warn!(user = %user_id, error = %e,
|
warn!(user = %user_id, error = %e,
|
||||||
"user-lifecycle: failed to provision container (retried at next boot)");
|
"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 } => {
|
SystemEvent::UserDeleted { user_id } => {
|
||||||
if let Err(e) = skald.container().remove(&user_id).await {
|
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,
|
warn!(user = %user_id, active, error = %e,
|
||||||
"user-lifecycle: failed to apply active-state change to container");
|
"user-lifecycle: failed to apply active-state change to container");
|
||||||
}
|
}
|
||||||
|
if active {
|
||||||
|
start_runtime_if_unencrypted(&skald, &user_id).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SystemEvent::UserMountsChanged { user_id } => {
|
SystemEvent::UserMountsChanged { user_id } => {
|
||||||
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
|
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
|
/// Spawns the **skills-freshness reactor** — the subscriber that turns a
|
||||||
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
|
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
|
||||||
/// §8.3).
|
/// §8.3).
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ impl UserManager {
|
|||||||
if let Some(pool) = self.pool_of(id) {
|
if let Some(pool) = self.pool_of(id) {
|
||||||
return Ok(pool);
|
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)
|
let user = db::users::get(&self.system, id)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::Internal)?
|
.map_err(AuthError::Internal)?
|
||||||
@@ -210,6 +216,57 @@ impl UserManager {
|
|||||||
db::open_user_pool(&path, None).await.map_err(AuthError::Internal)
|
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.
|
/// Login and unlock in one operation.
|
||||||
///
|
///
|
||||||
/// For an encrypted user a single Argon2id pass answers both questions: the
|
/// For an encrypted user a single Argon2id pass answers both questions: the
|
||||||
@@ -250,9 +307,14 @@ impl UserManager {
|
|||||||
.await
|
.await
|
||||||
.map_err(AuthError::Internal)?;
|
.map_err(AuthError::Internal)?;
|
||||||
|
|
||||||
// Another task may have unlocked the same user while we were deriving.
|
Ok(self.register_unlocked(id, pool, user.is_encrypted()).await)
|
||||||
// Whoever landed first wins; ours is closed below, outside the lock,
|
}
|
||||||
// since `close()` is async.
|
|
||||||
|
/// 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 winner = {
|
||||||
let mut map = self.unlocked.write().expect("unlocked map poisoned");
|
let mut map = self.unlocked.write().expect("unlocked map poisoned");
|
||||||
match map.entry(id.to_string()) {
|
match map.entry(id.to_string()) {
|
||||||
@@ -267,11 +329,11 @@ impl UserManager {
|
|||||||
match winner {
|
match winner {
|
||||||
Some(winner) => {
|
Some(winner) => {
|
||||||
pool.close().await;
|
pool.close().await;
|
||||||
Ok(winner)
|
winner
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
info!(user = %id, encrypted = user.is_encrypted(), "user database unlocked");
|
info!(user = %id, encrypted, "user database unlocked");
|
||||||
Ok(pool)
|
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]
|
#[tokio::test]
|
||||||
async fn lock_all_drops_every_key() {
|
async fn lock_all_drops_every_key() {
|
||||||
let f = Fixture::new("lockall").await;
|
let f = Fixture::new("lockall").await;
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ The report is kept where the supervisors can read it rather than in the reviewed
|
|||||||
|
|
||||||
## Why a run can be missing
|
## Why a run can be missing
|
||||||
|
|
||||||
Users are handled one at a time, and a user is **skipped** if they have not logged in since the server last restarted.
|
Users are handled one at a time, and a user with an **encrypted** space is **skipped** if they have not logged in since the server last restarted.
|
||||||
|
|
||||||
This is not a fault, it is how the encryption works: a person's data is unreadable until they log in and their password unlocks it. Until that happens there is nothing to read and nowhere to write. Nothing is lost — events keep accumulating, and the first run after they log in picks up everything waiting.
|
This is not a fault, it is how the encryption works: their data is unreadable until they log in and their password unlocks it. Until that happens there is nothing to read and nowhere to write. Nothing is lost — events keep accumulating, and the first run after they log in picks up everything waiting.
|
||||||
|
|
||||||
So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they had logged in at the time. The same applies to the shared memory lint: it needs an admin who has logged in since the restart.
|
Someone whose space is **not** encrypted is picked up as soon as the server starts, with no login at all: there is no key to wait for, so their agents (and their scheduled tasks, and their Telegram chat) work straight after a restart.
|
||||||
|
|
||||||
|
So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they have an encrypted space, and if so whether they had logged in at the time. The same applies to the shared memory lint: it needs an admin who is available — which for an encrypted admin means logged in since the restart.
|
||||||
|
|
||||||
Schedules are counted **per person from their own last run**, and they survive a restart — so a weekly pass stays weekly even on a machine that gets rebooted every few days.
|
Schedules are counted **per person from their own last run**, and they survive a restart — so a weekly pass stays weekly even on a machine that gets rebooted every few days.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user