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
+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());
}
}