//! 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 } } }