Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s

This commit is contained in:
2026-07-20 12:54:56 +01:00
parent b6128e4053
commit 44dc67cda0
29 changed files with 1631 additions and 110 deletions
+13
View File
@@ -343,6 +343,19 @@ impl ChatHub {
Some(sid) => sid,
None => return Ok(()), // no prior session, nothing to resume
};
// Guard against double-driving. A client sends `resume` on connect whenever
// history shows a pending/interrupted tool — including when the turn is still
// live and merely awaiting an approval. Without this check `resume_turn` would
// block on the `processing` lock and, once the approval unblocks the original
// turn and it finishes, run a spurious *second* turn on the just-completed
// conversation. If a turn is already in flight it owns the session and emits
// its own events, so there is nothing to resume — skip.
if let Ok(handler) = self.session_handler(source_id).await {
if handler.is_processing() {
info!(source_id, "ChatHub::resume: turn already in flight — skipping resume");
return Ok(());
}
}
self.resume_session(session_id).await
}
+145 -1
View File
@@ -1,5 +1,5 @@
use anyhow::{Result, bail};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
/// The built-in admin role — immutable from the API.
@@ -20,6 +20,81 @@ fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role {
Role { id, label, permission_group, attrs, created_at }
}
// ── Typed view over `roles.attrs` (§0.1: role attributes live in free-form JSON,
// never per-attribute columns) ────────────────────────────────────────────────
/// Interface mode a role opts into. `full` unless the role explicitly chooses the
/// simplified UI; `admin` is resolved to `full` upstream. Values other than the two
/// known ones fall back to `full` (tolerant parse).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UiMode {
#[default]
Full,
Simple,
}
impl UiMode {
pub fn as_str(self) -> &'static str {
match self {
UiMode::Full => "full",
UiMode::Simple => "simple",
}
}
}
/// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so
/// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse
/// error or missing key yields defaults.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct RoleAttrs {
pub ui_mode: UiMode,
/// Security-groups (`tool_permission_groups` ids) this role may use **in addition**
/// to its default `permission_group`. The default is always implicitly allowed; the
/// effective set is `unique({permission_group} permission_groups)`.
pub permission_groups: Vec<String>,
}
impl RoleAttrs {
pub fn from_opt(attrs: &Option<String>) -> RoleAttrs {
attrs
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
}
}
impl Role {
pub fn attrs_parsed(&self) -> RoleAttrs {
RoleAttrs::from_opt(&self.attrs)
}
/// The security-groups this role may select: its default first, then any extras
/// from `attrs.permission_groups`, deduped.
pub fn effective_groups(&self) -> Vec<String> {
let mut out = vec![self.permission_group.clone()];
for g in self.attrs_parsed().permission_groups {
if !out.contains(&g) {
out.push(g);
}
}
out
}
}
/// Whether a role may use `group_id` as its session security-group. `admin` holds
/// every group by construction; a missing role allows nothing.
pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result<bool> {
if role_id == ADMIN_ROLE_ID {
return Ok(true);
}
match get(pool, role_id).await? {
Some(role) => Ok(role.effective_groups().iter().any(|g| g == group_id)),
None => Ok(false),
}
}
// ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
@@ -122,3 +197,72 @@ pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_db(tag: &str) -> String {
let dir = std::env::temp_dir().join(format!("skald-roles-{tag}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("system.db").to_str().unwrap().to_string()
}
fn role(permission_group: &str, attrs: Option<&str>) -> Role {
Role {
id: "member".into(),
label: "Member".into(),
permission_group: permission_group.into(),
attrs: attrs.map(str::to_string),
created_at: String::new(),
}
}
#[test]
fn role_attrs_are_tolerant() {
// Missing → defaults.
let a = RoleAttrs::from_opt(&None);
assert_eq!(a.ui_mode, UiMode::Full);
assert!(a.permission_groups.is_empty());
// Populated.
let a = RoleAttrs::from_opt(&Some(
r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(),
));
assert_eq!(a.ui_mode, UiMode::Simple);
assert_eq!(a.permission_groups, vec!["ops", "research"]);
// Malformed JSON → defaults, never an error.
let a = RoleAttrs::from_opt(&Some("not json".into()));
assert_eq!(a.ui_mode, UiMode::Full);
assert!(a.permission_groups.is_empty());
}
#[test]
fn effective_groups_prepends_default_and_dedups() {
let r = role("default", Some(r#"{"permission_groups":["ops","default","research"]}"#));
assert_eq!(r.effective_groups(), vec!["default", "ops", "research"]);
// No extras → just the default.
let r = role("kids", None);
assert_eq!(r.effective_groups(), vec!["kids"]);
}
#[tokio::test]
async fn role_allows_group_admin_member_and_unknown() {
let pool = crate::db::init_system_pool(&tmp_db("allows")).await.unwrap();
// admin is seeded by init and allows any group by construction.
assert!(role_allows_group(&pool, ADMIN_ROLE_ID, "anything").await.unwrap());
insert(&pool, "member", "Member", "default", Some(r#"{"permission_groups":["ops"]}"#))
.await
.unwrap();
assert!(role_allows_group(&pool, "member", "default").await.unwrap());
assert!(role_allows_group(&pool, "member", "ops").await.unwrap());
assert!(!role_allows_group(&pool, "member", "research").await.unwrap());
// An unknown role allows nothing.
assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap());
}
}
+1
View File
@@ -41,6 +41,7 @@ pub mod run_context;
pub mod secrets;
pub mod service_manager;
pub mod session;
pub mod setup;
pub mod tic;
pub mod tool_catalog;
pub mod tool_discovery;
+104
View File
@@ -100,6 +100,53 @@ impl RunContext {
}
}
/// Outcome of validating a client-supplied [`RunContext`] against the caller's role.
pub enum RunContextDecision {
/// Apply this (possibly sanitized) run-context to the session.
Apply(Option<RunContext>),
/// The requested security-group is not in the role's allowed set (→ 403); the
/// string is the offending group id.
Forbidden(String),
}
/// Gate a client-supplied run-context by the caller's role, closing two holes at
/// once (§0.1 — enforce server-side, never trust the client):
///
/// - **Group governance**: a non-admin may only select a security-group in its
/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else
/// is [`RunContextDecision::Forbidden`].
/// - **fs escalation**: for a non-admin every other `RunContext` field
/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is
/// **discarded** — the client can set the permission group, nothing more. A rich
/// run-context (a project's) is resolved server-side, never through this path.
///
/// `admin` is trusted and passes through unchanged. `None` (clear) is always
/// allowed and falls back to the role's default group at session build.
pub async fn validate_run_context_for_role(
registry_pool: &SqlitePool,
role_id: &str,
incoming: Option<RunContext>,
) -> Result<RunContextDecision> {
if role_id == crate::db::roles::ADMIN_ROLE_ID {
return Ok(RunContextDecision::Apply(incoming));
}
let Some(rc) = incoming else {
return Ok(RunContextDecision::Apply(None));
};
match rc.tool_group_id() {
// A non-admin that names no group is treated as a clear (→ default group).
None => Ok(RunContextDecision::Apply(None)),
Some(group) => {
if crate::db::roles::role_allows_group(registry_pool, role_id, group).await? {
let group = group.to_string();
Ok(RunContextDecision::Apply(Some(RunContext::with_security_group(Some(group)))))
} else {
Ok(RunContextDecision::Forbidden(group.to_string()))
}
}
}
}
pub struct RunContextManager {
db: Arc<SqlitePool>,
approval: Arc<ApprovalManager>,
@@ -373,4 +420,61 @@ mod tests {
std::fs::remove_dir_all(&wd).ok();
}
#[tokio::test]
async fn validate_admin_passes_through_untouched() {
let path = unique_tmp().join("system.db");
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
let rc = RunContext {
security_group: Some("ops".into()),
allow_fs_writes: vec!["/etc".into()],
..Default::default()
};
match validate_run_context_for_role(&pool, "admin", Some(rc)).await.unwrap() {
RunContextDecision::Apply(Some(got)) => {
assert_eq!(got.tool_group_id(), Some("ops"));
assert_eq!(got.allow_fs_writes, vec!["/etc".to_string()]);
}
_ => panic!("admin must pass through unchanged"),
}
}
#[tokio::test]
async fn validate_non_admin_gates_group_and_strips_fs() {
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, "member", "Member", "default",
Some(r#"{"permission_groups":["ops"]}"#)).await.unwrap();
// Allowed group: kept, but every other field is discarded (fs hardening).
let rc = RunContext {
security_group: Some("ops".into()),
allow_fs_writes: vec!["/etc".into()],
system_prompt: vec!["ignore me".into()],
..Default::default()
};
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
RunContextDecision::Apply(Some(got)) => {
assert_eq!(got.tool_group_id(), Some("ops"));
assert!(got.allow_fs_writes.is_empty());
assert!(got.system_prompt.is_empty());
}
_ => panic!("an allowed group must apply, sanitized"),
}
// A group outside the role's set is refused.
let rc = RunContext { security_group: Some("secret".into()), ..Default::default() };
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
RunContextDecision::Forbidden(g) => assert_eq!(g, "secret"),
_ => panic!("a group outside the set must be forbidden"),
}
// Clearing is always allowed (falls back to the role default at build time).
match validate_run_context_for_role(&pool, "member", None).await.unwrap() {
RunContextDecision::Apply(None) => {}
_ => panic!("clear must be allowed"),
}
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
}
+159
View File
@@ -0,0 +1,159 @@
//! First-run instance initialization — the seam both setup shells share.
//!
//! `skald-setup` (the terminal wizard) and the web setup endpoint both need to do
//! the same thing exactly once: seed the instance's roles from a chosen **seed
//! profile** and create the first admin. Keeping that here — rather than duplicated
//! in each shell — is what stops the two paths from drifting apart.
//!
//! A [`SeedProfile`] is the neutral primitive (§0.1); the domain flavour ("Family",
//! "Office", …) lives only in the profile's seed data — labels and role presets,
//! never in the engine. One profile ships today; adding another is data, not code.
use anyhow::{Result, anyhow};
use sqlx::SqlitePool;
use crate::db::{self, roles::ADMIN_ROLE_ID};
use crate::users::UserManager;
/// One role a profile seeds. `attrs` is the `roles.attrs` JSON (§0.1) — `ui_mode`,
/// allowed security-groups, and future role attributes.
pub struct RoleSeed {
pub id: &'static str,
pub label: &'static str,
pub permission_group: &'static str,
pub attrs: Option<&'static str>,
}
/// A named preset of roles the admin picks at first-run. Neutral mechanism; the
/// domain lives in the data.
pub struct SeedProfile {
pub id: &'static str,
pub label: &'static str,
pub roles: Vec<RoleSeed>,
}
/// The profiles offered by the setup picker. `admin` is seeded universally at
/// table-creation (an FK invariant, `db::roles::seed_admin`), so a profile only
/// adds its **domain** roles. Ship one now; `office` / `family-no-kids` are just
/// more entries here — no engine change.
pub fn seed_profiles() -> Vec<SeedProfile> {
vec![SeedProfile {
id: "family",
label: "Family",
roles: vec![
RoleSeed {
id: "member",
label: "Member",
permission_group: "default",
attrs: Some(r#"{"ui_mode":"full"}"#),
},
RoleSeed {
id: "children",
label: "Children",
permission_group: "default",
attrs: Some(r#"{"ui_mode":"simple"}"#),
},
],
}]
}
/// Look up a profile by id.
pub fn seed_profile(id: &str) -> Option<SeedProfile> {
seed_profiles().into_iter().find(|p| p.id == id)
}
/// Seed a profile's roles (+ their default self-service capabilities) into the
/// registry. Idempotent: an existing role id is left untouched, so a re-run never
/// clobbers an admin-edited role. Runs at first-run, after every registry table
/// exists — so `role_capabilities` is present (no ordering hazard).
pub async fn apply_seed_profile(pool: &SqlitePool, profile_id: &str) -> Result<()> {
let profile =
seed_profile(profile_id).ok_or_else(|| anyhow!("unknown seed profile: {profile_id}"))?;
for role in &profile.roles {
if db::roles::get(pool, role.id).await?.is_some() {
continue; // already present — leave it as the admin left it
}
db::roles::insert(pool, role.id, role.label, role.permission_group, role.attrs).await?;
// The standard self-service capabilities, exactly as `roles::create` grants
// them through the API (§14).
db::role_capabilities::seed_defaults(pool, role.id).await?;
}
Ok(())
}
/// Everything a shell needs to know about the first admin.
pub struct FirstAdmin<'a> {
pub username: &'a str,
pub display_name: Option<&'a str>,
pub password: Option<&'a str>,
pub encrypted: bool,
/// Interface language → the instance default (`ui_locale`). `None` leaves the
/// registry default (English) in place.
pub locale: Option<&'a str>,
}
/// First-run initialization, shared by both setup shells: apply the chosen seed
/// profile, create the admin, set the instance default locale. Returns the new
/// admin's user id.
///
/// The default-locale write goes straight to `db::config` (no system bus): at
/// first-run nothing is listening, so both shells converge on the same path.
pub async fn initialize_instance(
users: &UserManager,
pool: &SqlitePool,
profile_id: &str,
admin: FirstAdmin<'_>,
) -> Result<String> {
apply_seed_profile(pool, profile_id).await?;
let id = users
.register_user(
admin.username,
admin.display_name,
ADMIN_ROLE_ID,
admin.password,
admin.encrypted,
)
.await?;
if let Some(locale) = admin.locale {
crate::i18n::set_default_locale(pool, locale).await?;
}
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::roles::UiMode;
fn tmp_db(tag: &str) -> String {
let dir = std::env::temp_dir().join(format!("skald-setup-{tag}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("system.db").to_str().unwrap().to_string()
}
#[tokio::test]
async fn family_profile_seeds_roles_and_caps_idempotently() {
let pool = crate::db::init_system_pool(&tmp_db("family")).await.unwrap();
apply_seed_profile(&pool, "family").await.unwrap();
apply_seed_profile(&pool, "family").await.unwrap(); // idempotent
let member = db::roles::get(&pool, "member").await.unwrap().unwrap();
assert_eq!(member.attrs_parsed().ui_mode, UiMode::Full);
let children = db::roles::get(&pool, "children").await.unwrap().unwrap();
assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple);
// The standard self-service capabilities were granted to a seeded role.
assert!(db::role_capabilities::has(
&pool, "member", db::role_capabilities::REGISTER_REMOTE,
).await.unwrap());
}
#[tokio::test]
async fn unknown_profile_is_an_error() {
let pool = crate::db::init_system_pool(&tmp_db("unknown")).await.unwrap();
assert!(apply_seed_profile(&pool, "does-not-exist").await.is_err());
}
}