From abaabb68d8dd3aa1be5f799f7be39f37913bbb5b Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 26 May 2026 21:40:04 +0200 Subject: [PATCH] feat(manager-core): add authz module with can() / require() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the three-layer capability check from blueprint §11.6: role grant (instance role + app_members) ∩ scope intersection (for API keys) ∩ app binding (for bound keys). Capabilities are finer than scopes (AppWriteScript vs AppWriteRoute, AppManageDomains vs AppAdmin) so a script:write-only key cannot mutate routes; scopes stay at the seven values the blueprint locks down. In-memory AuthzRepo fixture in the test module covers the full matrix: owner / admin / member behavior, scope intersection, bound key isolation, and instance:* denial on bound keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/authz.rs | 555 +++++++++++++++++++++++++++++++ crates/manager-core/src/lib.rs | 2 + 2 files changed, 557 insertions(+) create mode 100644 crates/manager-core/src/authz.rs diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs new file mode 100644 index 0000000..0152fca --- /dev/null +++ b/crates/manager-core/src/authz.rs @@ -0,0 +1,555 @@ +//! Capability-based authorization — see blueprint §11.6. +//! +//! Single entry point for every admin endpoint: `can(repo, principal, +//! capability)` returns whether the caller can perform the action. +//! Handlers call `require` (which wraps `can` + a `Forbidden` error) +//! after loading the resource so the capability binds to the resource's +//! actual `app_id`, not a path param the caller controls. +//! +//! Three layers of intersection, evaluated in order: +//! +//! 1. **Role grant** — does the caller's `InstanceRole` plus any +//! `app_members` row authorize this capability? +//! 2. **Scope intersection** — if the principal came from an API key +//! (`principal.scopes.is_some()`), does the key's scope set cover +//! the capability's required scope? +//! 3. **App binding** — if the key was minted bound to a specific +//! app (`principal.app_binding`), does the capability target the +//! same app? (Instance-level capabilities are denied for bound +//! keys; the mint handler also rejects the combination upfront.) +//! +//! The capability set is intentionally finer-grained than the seven +//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under +//! the `script:write` / `route:write` scopes respectively). Keeping +//! capabilities precise lets a `script:write`-only key write scripts +//! without also being able to mutate routes. The scope set stays at +//! seven values — capabilities are the internal check, scopes are the +//! external user-facing label. + +use async_trait::async_trait; +use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId}; + +/// Things a caller can attempt to do. Each app-scoped variant carries +/// the `AppId` of the resource the action targets — handlers compute +/// it from the loaded resource (e.g., `script.app_id`), not from a +/// path param. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// Create a new app. Owner / admin only. + InstanceCreateApp, + /// Create / update / delete admin_users rows (other than self + /// password change, which is a separate flow). Owner / admin. + InstanceManageUsers, + /// Mutate instance-wide configuration (sandbox ceiling, etc.). + /// Owner only. + InstanceManageSettings, + /// Read app metadata, scripts, routes. Viewer / editor / app_admin + /// (member); implicit for admin / owner. + AppRead(AppId), + /// Create / update / delete a script in this app. + AppWriteScript(AppId), + /// Create / update / delete a route in this app. + AppWriteRoute(AppId), + /// Manage domain claims on this app (add / remove). + AppManageDomains(AppId), + /// App settings + delete app. app_admin only (or owner via + /// implicit grant). + AppAdmin(AppId), + /// Read execution logs for scripts in this app. + AppLogRead(AppId), +} + +impl Capability { + /// Extract the `AppId` for app-scoped capabilities; `None` for + /// instance-scoped ones. Used by the app-binding check on API keys. + #[must_use] + pub const fn app_id(self) -> Option { + match self { + Self::InstanceCreateApp + | Self::InstanceManageUsers + | Self::InstanceManageSettings => None, + Self::AppRead(id) + | Self::AppWriteScript(id) + | Self::AppWriteRoute(id) + | Self::AppManageDomains(id) + | Self::AppAdmin(id) + | Self::AppLogRead(id) => Some(id), + } + } + + /// The single scope that authorizes this capability on an API key. + /// Strict mapping — a `script:write` key cannot read scripts unless + /// it also carries `script:read`. The intent is predictability: a + /// key has exactly the scopes it was minted with, no implicit + /// upgrades. + #[must_use] + pub const fn required_scope(self) -> Scope { + match self { + Self::InstanceCreateApp + | Self::InstanceManageUsers + | Self::InstanceManageSettings => Scope::InstanceAdmin, + Self::AppRead(_) => Scope::ScriptRead, + Self::AppWriteScript(_) => Scope::ScriptWrite, + Self::AppWriteRoute(_) => Scope::RouteWrite, + Self::AppManageDomains(_) => Scope::DomainManage, + Self::AppAdmin(_) => Scope::AppAdmin, + Self::AppLogRead(_) => Scope::LogRead, + } + } +} + +/// Repo seam for membership lookups. Implemented in the DB-backed +/// repos crate (`app_members_repo.rs`); keeping it as a trait here +/// means unit tests can stub it. +#[async_trait] +pub trait AuthzRepo: Send + Sync { + async fn membership( + &self, + user_id: UserId, + app_id: AppId, + ) -> Result, AuthzError>; +} + +/// Repo errors surface here so handlers can map them to 500 without +/// dragging sqlx types across the boundary. +#[derive(Debug, thiserror::Error)] +pub enum AuthzError { + #[error("authorization repo error: {0}")] + Repo(String), +} + +/// Decision flavor returned by `can` — distinguishes outright denial +/// from a partial answer that requires further checks (none today, +/// but the shape lets us add audit/explain mode later without rewriting +/// every caller). +#[must_use = "an authorization decision must be acted on"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + Allow, + Deny, +} + +impl Decision { + #[must_use] + pub const fn is_allow(self) -> bool { + matches!(self, Self::Allow) + } +} + +/// Core authorization check. Walks the three intersection layers in +/// order and returns the resulting `Decision`. +pub async fn can( + repo: &dyn AuthzRepo, + principal: &Principal, + cap: Capability, +) -> Result { + if !role_grants(repo, principal, cap).await? { + return Ok(Decision::Deny); + } + if !scope_allows(principal, cap) { + return Ok(Decision::Deny); + } + if !binding_allows(principal, cap) { + return Ok(Decision::Deny); + } + Ok(Decision::Allow) +} + +/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny. +/// Handlers call this so the `?` operator threads the 403 through +/// naturally. +/// +/// # Errors +/// +/// Returns `AuthzDenied::Denied` when the capability is not granted, +/// or `AuthzDenied::Repo` if the underlying membership lookup fails. +pub async fn require( + repo: &dyn AuthzRepo, + principal: &Principal, + cap: Capability, +) -> Result<(), AuthzDenied> { + match can(repo, principal, cap).await { + Ok(Decision::Allow) => Ok(()), + Ok(Decision::Deny) => Err(AuthzDenied::Denied), + Err(e) => Err(AuthzDenied::Repo(e)), + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthzDenied { + #[error("forbidden")] + Denied, + #[error(transparent)] + Repo(#[from] AuthzError), +} + +// ---------------------------------------------------------------------------- +// Layer 1: role-derived grant +// ---------------------------------------------------------------------------- + +async fn role_grants( + repo: &dyn AuthzRepo, + principal: &Principal, + cap: Capability, +) -> Result { + match principal.instance_role { + InstanceRole::Owner => Ok(true), + InstanceRole::Admin => Ok(admin_grants(cap)), + InstanceRole::Member => member_grants(repo, principal.user_id, cap).await, + } +} + +/// Admin is implicit `editor` on every app (per blueprint §11.6). They +/// can create apps and manage users, but NOT touch instance-wide +/// settings or take app-admin-only actions on apps they're not +/// explicitly app_admin of. Everything not in this set falls through +/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`). +const fn admin_grants(cap: Capability) -> bool { + matches!( + cap, + Capability::InstanceCreateApp + | Capability::InstanceManageUsers + | Capability::AppRead(_) + | Capability::AppWriteScript(_) + | Capability::AppWriteRoute(_) + | Capability::AppLogRead(_) + ) +} + +/// Member has zero instance authority. App authority requires an +/// explicit `app_members` row with sufficient `AppRole`. +async fn member_grants( + repo: &dyn AuthzRepo, + user_id: UserId, + cap: Capability, +) -> Result { + let Some(app_id) = cap.app_id() else { + return Ok(false); + }; + let Some(role) = repo.membership(user_id, app_id).await? else { + return Ok(false); + }; + Ok(role_satisfies(role, cap)) +} + +/// Does the per-app `AppRole` cover the capability? Viewer can read; +/// Editor adds script/route/log mutations; AppAdmin adds settings, +/// domain claims, and delete. Roles form a strict subset chain, so +/// the check is "is this capability in the role's set?". +const fn role_satisfies(role: AppRole, cap: Capability) -> bool { + let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_)); + let in_editor = in_viewer + || matches!( + cap, + Capability::AppWriteScript(_) | Capability::AppWriteRoute(_) + ); + let in_app_admin = in_editor + || matches!( + cap, + Capability::AppManageDomains(_) | Capability::AppAdmin(_) + ); + match role { + AppRole::Viewer => in_viewer, + AppRole::Editor => in_editor, + AppRole::AppAdmin => in_app_admin, + } +} + +// ---------------------------------------------------------------------------- +// Layer 2: API-key scope intersection +// ---------------------------------------------------------------------------- + +fn scope_allows(principal: &Principal, cap: Capability) -> bool { + match &principal.scopes { + None => true, // cookie session — full role authority + Some(scopes) => scopes.contains(&cap.required_scope()), + } +} + +// ---------------------------------------------------------------------------- +// Layer 3: API-key app binding +// ---------------------------------------------------------------------------- + +fn binding_allows(principal: &Principal, cap: Capability) -> bool { + let Some(bound_app) = principal.app_binding else { + return true; + }; + match cap.app_id() { + // Instance-scoped capability + bound key → always denied. The + // mint handler also rejects this combination upfront, but + // defending in depth here means a stale/malformed row can't + // escalate. + None => false, + Some(target_app) => target_app == bound_app, + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use picloud_shared::{AdminUserId, AppId}; + use std::collections::HashMap; + use tokio::sync::Mutex; + + /// In-memory `AuthzRepo` so the unit tests don't need a database. + #[derive(Default)] + struct InMemoryAuthzRepo { + memberships: Mutex>, + } + + impl InMemoryAuthzRepo { + async fn grant(&self, user: UserId, app: AppId, role: AppRole) { + self.memberships.lock().await.insert((user, app), role); + } + } + + #[async_trait] + impl AuthzRepo for InMemoryAuthzRepo { + async fn membership( + &self, + user_id: UserId, + app_id: AppId, + ) -> Result, AuthzError> { + Ok(self.memberships.lock().await.get(&(user_id, app_id)).copied()) + } + } + + fn principal(role: InstanceRole) -> Principal { + Principal { + user_id: AdminUserId::new(), + instance_role: role, + scopes: None, + app_binding: None, + } + } + + #[tokio::test] + async fn owner_can_do_everything() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Owner); + let app = AppId::new(); + for cap in [ + Capability::InstanceCreateApp, + Capability::InstanceManageUsers, + Capability::InstanceManageSettings, + Capability::AppRead(app), + Capability::AppWriteScript(app), + Capability::AppWriteRoute(app), + Capability::AppManageDomains(app), + Capability::AppAdmin(app), + Capability::AppLogRead(app), + ] { + assert_eq!( + can(&repo, &p, cap).await.unwrap(), + Decision::Allow, + "owner denied {cap:?}" + ); + } + } + + #[tokio::test] + async fn admin_cannot_manage_instance_settings_or_app_admin_actions() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Admin); + let app = AppId::new(); + assert_eq!( + can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(), + Decision::Allow, + ); + assert_eq!( + can(&repo, &p, Capability::InstanceManageUsers).await.unwrap(), + Decision::Allow, + ); + assert_eq!( + can(&repo, &p, Capability::InstanceManageSettings).await.unwrap(), + Decision::Deny, + ); + // Editor-like grants succeed + assert_eq!( + can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(), + Decision::Allow, + ); + assert_eq!( + can(&repo, &p, Capability::AppWriteRoute(app)).await.unwrap(), + Decision::Allow, + ); + // App-admin grants do not + assert_eq!( + can(&repo, &p, Capability::AppManageDomains(app)).await.unwrap(), + Decision::Deny, + ); + assert_eq!( + can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(), + Decision::Deny, + ); + } + + #[tokio::test] + async fn member_without_row_is_denied_everywhere() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Member); + let app = AppId::new(); + for cap in [ + Capability::InstanceCreateApp, + Capability::InstanceManageUsers, + Capability::InstanceManageSettings, + Capability::AppRead(app), + Capability::AppWriteScript(app), + Capability::AppWriteRoute(app), + Capability::AppAdmin(app), + Capability::AppLogRead(app), + ] { + assert_eq!( + can(&repo, &p, cap).await.unwrap(), + Decision::Deny, + "member granted {cap:?} without a membership row" + ); + } + } + + #[tokio::test] + async fn member_with_viewer_role_can_read_but_not_write() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Member); + let app = AppId::new(); + repo.grant(p.user_id, app, AppRole::Viewer).await; + + assert!(can(&repo, &p, Capability::AppRead(app)).await.unwrap().is_allow()); + assert!(can(&repo, &p, Capability::AppLogRead(app)).await.unwrap().is_allow()); + assert_eq!( + can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(), + Decision::Deny + ); + assert_eq!( + can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(), + Decision::Deny + ); + } + + #[tokio::test] + async fn member_with_editor_role_can_write_scripts_and_routes() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Member); + let app = AppId::new(); + repo.grant(p.user_id, app, AppRole::Editor).await; + + assert!(can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap().is_allow()); + assert!(can(&repo, &p, Capability::AppWriteRoute(app)).await.unwrap().is_allow()); + assert_eq!( + can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(), + Decision::Deny + ); + } + + #[tokio::test] + async fn member_with_app_admin_role_can_do_app_admin_actions() { + let repo = InMemoryAuthzRepo::default(); + let p = principal(InstanceRole::Member); + let app = AppId::new(); + repo.grant(p.user_id, app, AppRole::AppAdmin).await; + + assert!(can(&repo, &p, Capability::AppAdmin(app)).await.unwrap().is_allow()); + assert!(can(&repo, &p, Capability::AppManageDomains(app)).await.unwrap().is_allow()); + // Membership in App A does NOT grant access to App B + let other_app = AppId::new(); + assert_eq!( + can(&repo, &p, Capability::AppAdmin(other_app)).await.unwrap(), + Decision::Deny + ); + } + + #[tokio::test] + async fn scoped_key_intersects_with_role() { + let repo = InMemoryAuthzRepo::default(); + let app = AppId::new(); + // Owner key with only script:read — cannot write + let p = Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: Some(vec![Scope::ScriptRead]), + app_binding: None, + }; + assert!(can(&repo, &p, Capability::AppRead(app)).await.unwrap().is_allow()); + assert_eq!( + can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(), + Decision::Deny + ); + // Even though the user is owner — the key's scope set is the + // hard ceiling. + assert_eq!( + can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(), + Decision::Deny + ); + } + + #[tokio::test] + async fn bound_key_cannot_escape_its_app() { + let repo = InMemoryAuthzRepo::default(); + let bound_app = AppId::new(); + let other_app = AppId::new(); + let p = Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: Some(vec![Scope::ScriptWrite]), + app_binding: Some(bound_app), + }; + assert!(can(&repo, &p, Capability::AppWriteScript(bound_app)) + .await + .unwrap() + .is_allow()); + assert_eq!( + can(&repo, &p, Capability::AppWriteScript(other_app)).await.unwrap(), + Decision::Deny + ); + } + + #[tokio::test] + async fn bound_key_cannot_do_instance_actions() { + let repo = InMemoryAuthzRepo::default(); + let bound_app = AppId::new(); + let p = Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo + app_binding: Some(bound_app), + }; + assert_eq!( + can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(), + Decision::Deny, + "bound key with instance scope must still be denied at the binding layer" + ); + } + + #[test] + fn capability_app_id_extraction() { + let app = AppId::new(); + assert_eq!(Capability::InstanceCreateApp.app_id(), None); + assert_eq!(Capability::AppRead(app).app_id(), Some(app)); + assert_eq!(Capability::AppAdmin(app).app_id(), Some(app)); + } + + #[test] + fn capability_required_scope_mapping_is_complete() { + // Sanity: every variant returns a scope. Compiler-enforced + // exhaustiveness lives in the match itself; this test guards + // against accidental drift to a default branch. + let app = AppId::new(); + for cap in [ + Capability::InstanceCreateApp, + Capability::InstanceManageUsers, + Capability::InstanceManageSettings, + Capability::AppRead(app), + Capability::AppWriteScript(app), + Capability::AppWriteRoute(app), + Capability::AppManageDomains(app), + Capability::AppAdmin(app), + Capability::AppLogRead(app), + ] { + let _ = cap.required_scope(); // does not panic + } + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 8ab778b..b9bc82b 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod auth; pub mod auth_api; pub mod auth_bootstrap; pub mod auth_middleware; +pub mod authz; pub mod log_sink; pub mod migrations; pub mod repo; @@ -43,6 +44,7 @@ pub use auth_bootstrap::{ bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError, }; pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; +pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; pub use log_sink::PostgresExecutionLogSink; pub use repo::{ ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,