feat(auth): login, roles, user mgmt, setup wizard, and session guard

- New skald-setup crate: interactive first-run wizard that creates the
  admin user, prompts for encryption choice and password
- Auth system: session-based login/logout with cookie, guard middleware
- Roles API: CRUD for data-driven roles, seeded on first boot
- Users management API: create, list, edit, delete users
- Setup state API: check if first admin has been created
- Frontend: login-page, setup-page, users-page, roles-page, profile-page
  components with corresponding CSS
- Topbar: avatar dropdown with profile link and logout
- Sidebar: nav entries for Users and Roles (admin only)
- Page shell CSS: layout support for the new pages
- build.sh: builds both skald and skald-setup binaries
- run.sh: runs skald-setup before the server loop
- CLAUDE.md: updated workspace layout and build/run docs
This commit is contained in:
2026-07-10 19:19:25 +01:00
parent 178a38357e
commit 7dd77d4ef4
36 changed files with 2660 additions and 27 deletions
+85
View File
@@ -0,0 +1,85 @@
//! Session management — the layer above `UserManager`.
//!
//! `SessionStore` maps an opaque session token to a user id, in RAM only.
//! It knows nothing about HTTP cookies: the handler layer parses the cookie
//! header and calls [`SessionStore::user_of`] / [`SessionStore::logout`].
//!
//! The session lifetime mirrors the process lifetime: on restart every pool is
//! dropped (§9), so a surviving session would be useless — the user must
//! re-authenticate to unlock the database anyway.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::info;
use crate::users::{AuthError, UserManager};
/// Cookie name used across all auth endpoints.
pub const COOKIE_NAME: &str = "skald_session";
pub struct SessionStore {
users: Arc<UserManager>,
sessions: RwLock<HashMap<String, String>>,
}
impl SessionStore {
pub fn new(users: Arc<UserManager>) -> Self {
Self {
users,
sessions: RwLock::new(HashMap::new()),
}
}
/// Authenticates the user and unlocks their database, then mints a session
/// token. Returns the token so the caller can set it as a cookie.
pub async fn login(
&self,
username: &str,
password: &str,
) -> Result<String, AuthError> {
let user = self
.users
.by_username(username)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
// Always verify the password, even if the pool is already unlocked —
// open_db's idempotency short-circuit would skip the check otherwise.
self.users.verify_credentials(&user.id, password).await?;
// open_db handles pool lifecycle: unlocks if needed, no-op if open.
self.users.open_db(&user.id, Some(password)).await?;
let token = uuid::Uuid::new_v4().to_string();
self.sessions
.write()
.expect("sessions map poisoned")
.insert(token.clone(), user.id.clone());
info!(user = %user.id, %username, "session created");
Ok(token)
}
/// Returns the user id for a session token, or `None` if unknown / expired.
pub fn user_of(&self, token: &str) -> Option<String> {
self.sessions
.read()
.ok()?
.get(token)
.cloned()
}
/// Removes a single session. The database pool stays open (§9).
pub fn logout(&self, token: &str) {
if let Some(user_id) = self
.sessions
.write()
.expect("sessions map poisoned")
.remove(token)
{
info!(user = %user_id, "session removed");
}
}
}
+16 -5
View File
@@ -13,6 +13,7 @@ pub mod llm_requests;
pub mod mcp_events;
pub mod mcp_servers;
pub mod plugins;
pub mod roles;
pub mod scheduled_jobs;
pub mod scratchpad;
pub mod session_mcp_grants;
@@ -349,16 +350,26 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
// the registry — which means it must never hold anything that derives a
// user's key: `database_password` is the DEK sealed under a key derived
// from the password, useless without it.
//
// `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on
// `PRAGMA foreign_keys`, so pointing at a table that does not exist would
// make every INSERT fail. The constraint lands with the `roles` table.
sqlx::query(
"CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
permission_group TEXT NOT NULL,
attrs TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
crate::db::roles::seed_admin(pool).await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT,
role_id TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles(id),
encrypted INTEGER NOT NULL,
kdf_params TEXT,
kdf_salt BLOB,
+124
View File
@@ -0,0 +1,124 @@
use anyhow::{Result, bail};
use serde::Serialize;
use sqlx::SqlitePool;
/// The built-in admin role — immutable from the API.
pub const ADMIN_ROLE_ID: &str = "admin";
#[derive(Debug, Clone, Serialize)]
pub struct Role {
pub id: String,
pub label: String,
pub permission_group: String,
pub attrs: Option<String>,
pub created_at: String,
}
type RawRow = (String, String, String, Option<String>, String);
fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role {
Role { id, label, permission_group, attrs, created_at }
}
// ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
let rows = sqlx::query_as::<_, RawRow>(
"SELECT id, label, permission_group, attrs, created_at
FROM roles
ORDER BY label",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(from_raw).collect())
}
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<Role>> {
let row = sqlx::query_as::<_, RawRow>(
"SELECT id, label, permission_group, attrs, created_at
FROM roles WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(from_raw))
}
// ── Writes ───────────────────────────────────────────────────────────────────
pub async fn insert(
pool: &SqlitePool,
id: &str,
label: &str,
permission_group: &str,
attrs: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO roles (id, label, permission_group, attrs)
VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(label)
.bind(permission_group)
.bind(attrs)
.execute(pool)
.await?;
Ok(())
}
pub async fn update(
pool: &SqlitePool,
id: &str,
label: &str,
permission_group: &str,
attrs: Option<&str>,
) -> Result<bool> {
let rows = sqlx::query(
"UPDATE roles SET label = ?, permission_group = ?, attrs = ? WHERE id = ?",
)
.bind(label)
.bind(permission_group)
.bind(attrs)
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(rows > 0)
}
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
if id == ADMIN_ROLE_ID {
bail!("cannot delete the built-in admin role");
}
let rows = sqlx::query("DELETE FROM roles WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
bail!("no such role: {id}");
}
Ok(())
}
/// How many users are assigned to a role — prevents deletion when non-zero.
pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result<i64> {
let (n,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users WHERE role_id = ?")
.bind(role_id)
.fetch_one(pool)
.await?;
Ok(n)
}
// ── Seed ─────────────────────────────────────────────────────────────────────
/// Inserts the built-in `admin` role. Idempotent.
pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO roles (id, label, permission_group)
VALUES ('admin', 'Administrator', 'default')",
)
.execute(pool)
.await?;
Ok(())
}
+26
View File
@@ -327,6 +327,32 @@ pub async fn set_active(pool: &SqlitePool, id: &str, active: bool) -> Result<()>
Ok(())
}
/// Changes the role assignment and optionally the display name in one statement.
pub async fn update_profile(
pool: &SqlitePool,
id: &str,
username: &str,
display_name: Option<&str>,
role_id: &str,
) -> Result<()> {
let n = sqlx::query(
"UPDATE users
SET username = ?2, display_name = ?3, role_id = ?4, updated_at = datetime('now')
WHERE id = ?1",
)
.bind(id)
.bind(username)
.bind(display_name)
.bind(role_id)
.execute(pool)
.await?
.rows_affected();
if n == 0 {
bail!("no such user: {id}");
}
Ok(())
}
pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: Option<&str>) -> Result<()> {
let n = sqlx::query(
"UPDATE users SET username = ?2, display_name = ?3, updated_at = datetime('now')
+1
View File
@@ -7,6 +7,7 @@
pub mod boot;
pub mod config;
pub mod auth;
pub mod config_store;
pub mod skald;
pub mod agents;
+1
View File
@@ -52,6 +52,7 @@ impl Skald {
// Runtime / cross-cutting
pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db }
pub fn users(&self) -> &Arc<UserManager> { &self.rt.users }
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
pub fn system_bus(&self) -> &Arc<SystemEventBus> { &self.rt.system_bus }
+4
View File
@@ -17,6 +17,7 @@ use tracing::info;
use core_api::events::GlobalEvent;
use core_api::system_bus::SystemEventBus;
use crate::auth::SessionStore;
use crate::chat_event_bus::ChatEventBus;
use crate::config_store::GlobalConfigManager;
use crate::users::UserManager;
@@ -28,6 +29,7 @@ pub(super) struct Runtime {
/// writes: nothing has moved to per-user pools yet. `users` owns those.
pub(super) db: Arc<SqlitePool>,
pub(super) users: Arc<UserManager>,
pub(super) sessions: Arc<SessionStore>,
pub(super) config: Arc<GlobalConfigManager>,
pub(super) config_properties: Vec<core_api::ConfigSet>,
pub(super) system_bus: Arc<SystemEventBus>,
@@ -46,6 +48,7 @@ impl Runtime {
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool)));
let users = Arc::new(UserManager::new(Arc::clone(&pool)));
let sessions = Arc::new(SessionStore::new(Arc::clone(&users)));
let system_bus = Arc::new(SystemEventBus::new());
info!("system event bus ready");
@@ -58,6 +61,7 @@ impl Runtime {
Runtime {
db: pool,
users,
sessions,
config,
config_properties: vec![crate::tic::config_set()],
system_bus,
+15
View File
@@ -135,6 +135,21 @@ impl UserManager {
db::users::count(&self.system).await
}
/// Verifies the password **always**, regardless of whether the pool is
/// already unlocked. Use this for login authentication; use [`open_db`]
/// only for pool lifecycle.
pub async fn verify_credentials(&self, id: &str, password: &str) -> Result<(), AuthError> {
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
if !user.active {
return Err(AuthError::Inactive);
}
self.authenticate(&user, Some(password)).await?;
Ok(())
}
// ── Unlock registry ───────────────────────────────────────────────────────
/// The user's pool, or `None` when the database is still locked (§9).