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