//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6. //! //! The `Principal` extracted by `manager-core::auth_middleware` lives //! here so handlers in every crate (and, later, the v1.1 SDKs in //! `executor-core`) can refer to the same shape without pulling in the //! manager crate. The authorization rules themselves live in //! `manager-core::authz` — this module is data only. //! //! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named //! the table `admin_users` to leave room for the v1.1 script-level //! `users` SDK feature (see blueprint §11.4 "Naming"); from the //! authorization layer's perspective an admin row is the principal //! identity, so we expose the alias rather than renaming the existing //! id type. use serde::{Deserialize, Serialize}; use crate::{AdminUserId, AppId}; /// Transitional alias — see module docs. pub type UserId = AdminUserId; /// Instance-wide role carried by every `admin_users` row. The DB /// representation is `text` (`'owner'|'admin'|'member'`), checked via /// a CHECK constraint in migration `0006_users_authz.sql`; this enum /// is the Rust mirror. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InstanceRole { /// Full instance control, manage other owners, implicit `app_admin` /// on every app. Multiple allowed. Owner, /// Create apps, invite users, implicit `editor` on every app. No /// instance-settings authority and no owner-management. Admin, /// Invited into specific apps via `app_members` only. No app /// creation, no invite authority. List endpoints filter strictly /// by membership at SQL. Member, } impl InstanceRole { /// Stable string form — matches the DB CHECK constraint values /// exactly. Used by repos and the seed/audit paths. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Owner => "owner", Self::Admin => "admin", Self::Member => "member", } } /// Inverse of `as_str` — used when reading a row out of Postgres. /// Returns `None` for unknown values so the caller can decide /// between failing loudly or skipping a bad row. #[must_use] pub fn from_db_str(s: &str) -> Option { match s { "owner" => Some(Self::Owner), "admin" => Some(Self::Admin), "member" => Some(Self::Member), _ => None, } } } /// Per-app role recorded in `app_members`. Members hold zero-or-one row /// per (user, app); owners and admins are not represented in the table /// (their app authority is implicit via `InstanceRole`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppRole { /// App settings, domain claims, delete. AppAdmin, /// CRUD on scripts, routes, sandbox config. Editor, /// Read scripts + execution logs. Viewer, } impl AppRole { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::AppAdmin => "app_admin", Self::Editor => "editor", Self::Viewer => "viewer", } } #[must_use] pub fn from_db_str(s: &str) -> Option { match s { "app_admin" => Some(Self::AppAdmin), "editor" => Some(Self::Editor), "viewer" => Some(Self::Viewer), _ => None, } } } /// API-key scope. Exactly seven values; new scopes need a blueprint /// edit before they're added here. Wire form is the colon-separated /// string (`"script:read"`, etc.) — matches the `text[]` stored in /// `api_keys.scopes` and the strings shown to operators. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Scope { ScriptRead, ScriptWrite, RouteWrite, DomainManage, LogRead, AppAdmin, InstanceAdmin, } impl Scope { pub const ALL: &'static [Scope] = &[ Scope::ScriptRead, Scope::ScriptWrite, Scope::RouteWrite, Scope::DomainManage, Scope::LogRead, Scope::AppAdmin, Scope::InstanceAdmin, ]; #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::ScriptRead => "script:read", Self::ScriptWrite => "script:write", Self::RouteWrite => "route:write", Self::DomainManage => "domain:manage", Self::LogRead => "log:read", Self::AppAdmin => "app:admin", Self::InstanceAdmin => "instance:admin", } } #[must_use] pub fn from_wire(s: &str) -> Option { Self::ALL.iter().copied().find(|sc| sc.as_str() == s) } /// True for scopes that only make sense on an unbound key — bound /// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide /// authority and the mint handler rejects the combination at 422. #[must_use] pub const fn is_instance(self) -> bool { matches!(self, Self::InstanceAdmin) } } // Custom serde so the wire form is the colon-separated string. The // stored DB value lives in a `text[]`, so the repo converts between // `Vec` and `Vec` using `as_str`/`from_wire`. impl Serialize for Scope { fn serialize(&self, s: S) -> Result { s.serialize_str(self.as_str()) } } impl<'de> Deserialize<'de> for Scope { fn deserialize>(d: D) -> Result { let s = String::deserialize(d)?; Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}"))) } } /// Resolved caller identity. Produced by `manager-core::auth_middleware` /// for both the cookie-session path (then `scopes`/`app_binding` are /// `None`) and the bearer-API-key path (then both fields carry the /// key's constraints). /// /// The capability check in `manager-core::authz::can` intersects /// `instance_role` with `scopes` and `app_binding` to decide whether /// a given `Capability` is granted. #[derive(Debug, Clone)] pub struct Principal { pub user_id: UserId, pub instance_role: InstanceRole, /// `None` for cookie sessions (no scope restriction beyond the /// role itself); `Some` for API keys, in which case the effective /// authority is `role ∩ scopes`. pub scopes: Option>, /// `Some(app)` for keys bound to a single app at mint time. Every /// `App*(other)` capability is denied regardless of role. pub app_binding: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn instance_role_round_trip() { for role in [InstanceRole::Owner, InstanceRole::Admin, InstanceRole::Member] { assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role)); } assert_eq!(InstanceRole::from_db_str("bogus"), None); } #[test] fn app_role_round_trip() { for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] { assert_eq!(AppRole::from_db_str(role.as_str()), Some(role)); } assert_eq!(AppRole::from_db_str("bogus"), None); } #[test] fn scope_round_trip_covers_all() { for &scope in Scope::ALL { assert_eq!(Scope::from_wire(scope.as_str()), Some(scope)); } assert_eq!(Scope::from_wire("script:nope"), None); } #[test] fn scope_is_instance_flags_only_instance_admin() { for &scope in Scope::ALL { let expected = scope == Scope::InstanceAdmin; assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str()); } } #[test] fn scope_serde_uses_wire_form() { let s = serde_json::to_string(&Scope::ScriptWrite).unwrap(); assert_eq!(s, "\"script:write\""); let back: Scope = serde_json::from_str(&s).unwrap(); assert_eq!(back, Scope::ScriptWrite); let err = serde_json::from_str::("\"unknown\"").unwrap_err(); assert!(err.to_string().contains("unknown scope")); } }