Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
Nightly Build / build (push) Failing after 6m12s
This commit is contained in:
@@ -109,22 +109,21 @@ pub async fn me(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key
|
||||
/// resolves to "full" — the simplified UI is strictly opt-in.
|
||||
/// Reads `roles.attrs.ui_mode` for the given role via the typed [`RoleAttrs`]
|
||||
/// (the single attrs parse point). Any error or missing key resolves to "full" —
|
||||
/// the simplified UI is strictly opt-in, and `admin` is always "full".
|
||||
async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String {
|
||||
if role_id == skald_core::db::roles::ADMIN_ROLE_ID {
|
||||
use skald_core::db::roles;
|
||||
if role_id == roles::ADMIN_ROLE_ID {
|
||||
return "full".into();
|
||||
}
|
||||
let attrs = skald_core::db::roles::get(skald.db(), role_id)
|
||||
let ui_mode = roles::get(skald.db(), role_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|r| r.attrs);
|
||||
attrs
|
||||
.and_then(|a| serde_json::from_str::<serde_json::Value>(&a).ok())
|
||||
.and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned))
|
||||
.filter(|m| m == "simple" || m == "full")
|
||||
.unwrap_or_else(|| "full".into())
|
||||
.map(|r| r.attrs_parsed().ui_mode)
|
||||
.unwrap_or_default();
|
||||
ui_mode.as_str().into()
|
||||
}
|
||||
|
||||
// ── POST /api/auth/logout ────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,7 +47,12 @@ fn is_public(path: &str) -> bool {
|
||||
let p = path.strip_prefix("/api").unwrap_or(path);
|
||||
matches!(
|
||||
p,
|
||||
"/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user"
|
||||
"/auth/login"
|
||||
| "/auth/logout"
|
||||
| "/auth/me"
|
||||
| "/setup/status"
|
||||
| "/setup/user"
|
||||
| "/setup/profiles"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
|
||||
// First-run setup
|
||||
.route("/setup/status", get(setup::status))
|
||||
.route("/setup/profiles", get(setup::profiles))
|
||||
.route("/setup/user", post(setup::create_user))
|
||||
// Auth
|
||||
.route("/auth/login", post(auth::login))
|
||||
@@ -129,6 +130,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/tool-permission-groups", get(run_context::list_groups).post(run_context::create_group))
|
||||
.route("/tool-permission-groups/{id}", put(run_context::update_group).delete(run_context::delete_group))
|
||||
.route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_group))
|
||||
// The caller's own selectable security-groups (for the chat picker)
|
||||
.route("/my/security-groups", get(run_context::my_security_groups))
|
||||
// Session tool_group assignment (runtime)
|
||||
.route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context))
|
||||
// MCP / Connectors (blueprint §14/§15)
|
||||
|
||||
@@ -32,6 +32,9 @@ pub async fn create(
|
||||
if body.label.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("label must not be empty"));
|
||||
}
|
||||
if body.permission_group.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("permission group must not be empty"));
|
||||
}
|
||||
roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||
.await?;
|
||||
// Seed the standard self-service Connector capabilities (§14): a new role can
|
||||
@@ -57,6 +60,12 @@ pub async fn update(
|
||||
if id == ADMIN_ROLE_ID {
|
||||
return Err(ApiError::bad_request("the built-in admin role cannot be modified"));
|
||||
}
|
||||
if body.label.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("label must not be empty"));
|
||||
}
|
||||
if body.permission_group.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("permission group must not be empty"));
|
||||
}
|
||||
let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||
.await?;
|
||||
if !ok {
|
||||
|
||||
@@ -5,9 +5,10 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use skald_core::db::roles;
|
||||
use skald_core::skald::Skald;
|
||||
use super::{ApiError, guard::AuthUser, require_context};
|
||||
|
||||
@@ -79,6 +80,54 @@ pub async fn duplicate_group(
|
||||
Ok(Json(json!({ "id": body.id })))
|
||||
}
|
||||
|
||||
// ── GET /api/my/security-groups — the caller's selectable groups ──────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MySecurityGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// The security-groups the calling user may pick in the chat picker: its role's
|
||||
/// effective set joined with the group names (`admin` → every group). The composer
|
||||
/// renders this like the model list; the server still enforces the set on write.
|
||||
pub async fn my_security_groups(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<Json<Vec<MySecurityGroup>>, ApiError> {
|
||||
let user = skald
|
||||
.users()
|
||||
.get(&auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
|
||||
let all = skald.run_context_manager().list_groups().await?;
|
||||
|
||||
let (allowed, default_id): (Vec<String>, String) = if user.role_id == roles::ADMIN_ROLE_ID {
|
||||
(all.iter().map(|g| g.id.clone()).collect(), "default".to_string())
|
||||
} else {
|
||||
match roles::get(skald.db(), &user.role_id).await? {
|
||||
Some(role) => (role.effective_groups(), role.permission_group.clone()),
|
||||
None => (vec!["default".to_string()], "default".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
// Keep only ids that still exist as groups; carry the display name from there.
|
||||
let out = allowed
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
all.iter().find(|g| g.id == id).map(|g| MySecurityGroup {
|
||||
is_default: g.id == default_id,
|
||||
id: g.id.clone(),
|
||||
name: g.name.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ── Session run_context assignment ────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -92,6 +141,30 @@ pub async fn set_session_run_context(
|
||||
Json(ctx): Json<Option<skald_core::run_context::RunContext>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let uctx = require_context(&skald, &auth.user_id).await?;
|
||||
|
||||
// Gate the requested context by the caller's role: a non-admin may only pick a
|
||||
// security-group in its role's set, and every other RunContext field is dropped
|
||||
// (fs-escalation hardening). admin passes through. Same validator the WS path uses.
|
||||
let user = skald
|
||||
.users()
|
||||
.get(&auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
let ctx = match skald_core::run_context::validate_run_context_for_role(
|
||||
skald.db(),
|
||||
&user.role_id,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
skald_core::run_context::RunContextDecision::Apply(c) => c,
|
||||
skald_core::run_context::RunContextDecision::Forbidden(g) => {
|
||||
return Err(ApiError::forbidden(format!(
|
||||
"security group '{g}' is not allowed for your role"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// The session row (and its live handler) live in the caller's own pool, so the
|
||||
// persist + live update both target the user's context. Run-context *definitions*
|
||||
// (roles) remain instance-wide; only the per-session value is owner data.
|
||||
|
||||
@@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
|
||||
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, roles, sources};
|
||||
use skald_core::db::chat_sessions_stack::SessionStack;
|
||||
use skald_core::run_context::RunContext;
|
||||
use std::sync::Arc;
|
||||
use skald_core::skald::{Skald, UserContext};
|
||||
use skald_core::session::handler::ApprovalDecision;
|
||||
@@ -39,10 +40,32 @@ pub async fn create(
|
||||
// Resolve agent + RunContext from the source so project chats reset with the
|
||||
// coordinator agent (not the default `main`), then provision a fresh session.
|
||||
let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?;
|
||||
// A non-project chat inherits the caller role's default security-group, so a
|
||||
// restricted role starts scoped instead of on the catch-all `default` group.
|
||||
// Project chats already carry their own run-context and are left untouched.
|
||||
let rc = match rc {
|
||||
Some(rc) => Some(rc),
|
||||
None => role_default_run_context(&skald, &auth.user_id).await?,
|
||||
};
|
||||
ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?;
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
/// The default security-group a new session gets from the owner's role, or `None`
|
||||
/// when the role points at the catch-all `default` group (nothing to pin).
|
||||
async fn role_default_run_context(
|
||||
skald: &Skald,
|
||||
user_id: &str,
|
||||
) -> Result<Option<RunContext>, ApiError> {
|
||||
let Some(user) = skald.users().get(user_id).await? else { return Ok(None) };
|
||||
let Some(role) = roles::get(skald.db(), &user.role_id).await? else { return Ok(None) };
|
||||
let group = role.permission_group;
|
||||
if group.is_empty() || group == "default" {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(RunContext::with_security_group(Some(group))))
|
||||
}
|
||||
|
||||
// ── GET /api/web/messages ─────────────────────────────────────────────────────
|
||||
|
||||
pub async fn web_messages(
|
||||
|
||||
+45
-10
@@ -22,6 +22,24 @@ pub async fn status(State(skald): State<Arc<Skald>>) -> Result<Json<SetupStatus>
|
||||
Ok(Json(SetupStatus { needs_setup: count == 0 }))
|
||||
}
|
||||
|
||||
// ── GET /api/setup/profiles — seed profiles offered by the picker ────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SeedProfileInfo {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// The seed profiles the first-run picker offers (§0.1: the neutral mechanism,
|
||||
/// domain flavour in the data). Pre-auth, so it is on the setup allowlist.
|
||||
pub async fn profiles() -> Json<Vec<SeedProfileInfo>> {
|
||||
let list = skald_core::setup::seed_profiles()
|
||||
.into_iter()
|
||||
.map(|p| SeedProfileInfo { id: p.id.to_string(), label: p.label.to_string() })
|
||||
.collect();
|
||||
Json(list)
|
||||
}
|
||||
|
||||
// ── POST /api/setup/user — create the first (admin) user ────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -33,6 +51,9 @@ pub struct CreateUserBody {
|
||||
/// Chosen interface language — becomes the instance default (`ui_locale`).
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
/// Chosen seed profile id. Defaults to the first shipped profile.
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -63,17 +84,31 @@ pub async fn create_user(
|
||||
return Err(ApiError::bad_request("unsupported locale"));
|
||||
}
|
||||
}
|
||||
|
||||
let id = skald
|
||||
.users()
|
||||
.register_user(username, None, "admin", Some(&body.password), body.encrypted)
|
||||
.await?;
|
||||
|
||||
// The first-run language choice is instance-wide: it lands in the registry
|
||||
// config as the default every user follows until they override it.
|
||||
if let Some(l) = locale {
|
||||
skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?;
|
||||
let profile = body
|
||||
.profile
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("family");
|
||||
if skald_core::setup::seed_profile(profile).is_none() {
|
||||
return Err(ApiError::bad_request("unknown seed profile"));
|
||||
}
|
||||
|
||||
// The shared first-run seam: seed the profile's roles, create the admin, set
|
||||
// the default locale — the same path skald-setup takes, so the two never drift.
|
||||
let id = skald_core::setup::initialize_instance(
|
||||
skald.users(),
|
||||
skald.db(),
|
||||
profile,
|
||||
skald_core::setup::FirstAdmin {
|
||||
username,
|
||||
display_name: None,
|
||||
password: Some(&body.password),
|
||||
encrypted: body.encrypted,
|
||||
locale,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreateUserResult { user_id: id }))
|
||||
}
|
||||
|
||||
@@ -116,6 +116,23 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
running: session_handler.is_processing(),
|
||||
})).await;
|
||||
|
||||
// Tell this (possibly reloaded) client the session's current security-group so
|
||||
// the chat picker starts in sync. The twin of the model pill — but the group is
|
||||
// per-session persisted, not a per-source RAM pin, so it must be sent on connect.
|
||||
let _ = socket.send(to_msg(&ServerEvent::SecurityGroupSelected {
|
||||
group: current_session_group(&ctx.pool, &source).await,
|
||||
})).await;
|
||||
|
||||
// Keepalive: a long, silent turn (e.g. a slow `execute_cmd` producing no
|
||||
// events for a minute) sends nothing over the socket, so an idle proxy or the
|
||||
// browser can drop it. A dropped socket loses any event broadcast during the
|
||||
// ~2s reconnect gap — the bus is a `broadcast` with no replay — which is what
|
||||
// left an approved tool card stuck on "running" until a manual reload. A
|
||||
// periodic Ping keeps the connection warm. 25s beats common ~60s idle timeouts.
|
||||
let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
keepalive.tick().await; // consume the immediate first tick (don't ping on connect)
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// ── Inbound: message from the browser ────────────────────────────
|
||||
@@ -151,6 +168,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
if handle_question_answer_msg(&text, &session_handler).await { continue; }
|
||||
if handle_data_msg(&text, &skald) { continue; }
|
||||
if handle_select_client_msg(&text, &source, &chat_hub).await { continue; }
|
||||
if handle_select_security_group_msg(&text, &source, &user_id, &skald, &ctx, &session_handler).await { continue; }
|
||||
|
||||
// ── /sethome ──────────────────────────────────────────────────
|
||||
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||
@@ -414,6 +432,13 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keepalive tick: ping the client to keep the socket warm ───────
|
||||
_ = keepalive.tick() => {
|
||||
if socket.send(Message::Ping(Default::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,6 +521,84 @@ async fn handle_select_client_msg(
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns true if the message was a `select_security_group` control message
|
||||
/// (caller should `continue`). The twin of [`handle_select_client_msg`] for the
|
||||
/// session security-group: validate the requested group against the caller's role
|
||||
/// (§0.1 — enforce server-side, never trust the client: a non-admin may only pick a
|
||||
/// group in its role's set, and no other `RunContext` field is honoured), persist it
|
||||
/// on the session row, update the live handler, and broadcast `SecurityGroupSelected`
|
||||
/// so every open client stays in sync.
|
||||
async fn handle_select_security_group_msg(
|
||||
text: &str,
|
||||
source: &str,
|
||||
user_id: &str,
|
||||
skald: &Arc<Skald>,
|
||||
ctx: &Arc<skald_core::skald::UserContext>,
|
||||
session_handler: &Arc<skald_core::session::handler::ChatSessionHandler>,
|
||||
) -> bool {
|
||||
use skald_core::run_context::{RunContext, RunContextDecision, validate_run_context_for_role};
|
||||
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
|
||||
if v["type"].as_str() != Some("select_security_group") { return false }
|
||||
|
||||
// `group` is a string (pick) or null/absent (clear → the role's default group).
|
||||
let requested = v.get("group").and_then(|g| g.as_str()).map(str::to_string);
|
||||
let incoming = requested.map(|g| RunContext::with_security_group(Some(g)));
|
||||
|
||||
let Ok(Some(user)) = skald.users().get(user_id).await else { return true };
|
||||
let effective = match validate_run_context_for_role(skald.db(), &user.role_id, incoming).await {
|
||||
Ok(RunContextDecision::Apply(rc)) => rc,
|
||||
Ok(RunContextDecision::Forbidden(g)) => {
|
||||
warn!(source, group = %g, "select_security_group: not in role's set — ignored");
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "select_security_group: validation failed");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Persist on the session row (owner pool) and update the live handler.
|
||||
if let Ok(Some(sid)) = skald_core::db::sources::active_session_id(&ctx.pool, source).await {
|
||||
let _ = skald_core::db::chat_sessions::set_run_context(
|
||||
&ctx.pool,
|
||||
sid,
|
||||
effective.as_ref().map(|c| c.to_db()).as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
session_handler.set_run_context(effective.clone()).await;
|
||||
|
||||
// Broadcast the effective group id ("default" when cleared) to every client.
|
||||
let group = effective
|
||||
.as_ref()
|
||||
.and_then(|rc| rc.tool_group_id().map(str::to_string))
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
ctx.chat_hub.emit(skald_core::events::GlobalEvent {
|
||||
source: Some(source.to_string()),
|
||||
session_id: None,
|
||||
event: ServerEvent::SecurityGroupSelected { group },
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The active session's current security-group for `source`, or `"default"` when
|
||||
/// no session or no run-context is set. Used to seed a freshly-connected client.
|
||||
async fn current_session_group(pool: &sqlx::SqlitePool, source: &str) -> String {
|
||||
use skald_core::run_context::RunContext;
|
||||
let Ok(Some(sid)) = skald_core::db::sources::active_session_id(pool, source).await else {
|
||||
return "default".to_string();
|
||||
};
|
||||
let group = skald_core::db::chat_sessions::find_by_id(pool, sid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.run_context)
|
||||
.and_then(|s| RunContext::from_db(&s))
|
||||
.and_then(|rc| rc.tool_group_id().map(str::to_string));
|
||||
group.unwrap_or_else(|| "default".to_string())
|
||||
}
|
||||
|
||||
/// Returns true if the message was an inbound data push (caller should `continue`).
|
||||
/// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager.
|
||||
fn handle_data_msg(text: &str, skald: &Arc<Skald>) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user