From fd6f2b1f13f05dfc4426febd456838bf13cf9992 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 26 May 2026 21:35:25 +0200 Subject: [PATCH] feat(shared): add Principal, InstanceRole, AppRole, Scope, ApiKeyId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-crate authn/authz data types for Phase 3.5. The Principal struct is the resolved caller identity that auth_middleware will produce for both cookie sessions and bearer API keys; the role/scope enums mirror the DB CHECK constraints from migration 0006 and round-trip through their stable string forms. UserId is a type alias for AdminUserId — the auth layer treats an admin row as the principal identity, so the alias avoids a rename of the existing id type. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/shared/src/auth.rs | 238 ++++++++++++++++++++++++++++++++++++++ crates/shared/src/ids.rs | 1 + crates/shared/src/lib.rs | 4 +- 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 crates/shared/src/auth.rs diff --git a/crates/shared/src/auth.rs b/crates/shared/src/auth.rs new file mode 100644 index 0000000..7957d0c --- /dev/null +++ b/crates/shared/src/auth.rs @@ -0,0 +1,238 @@ +//! 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")); + } +} diff --git a/crates/shared/src/ids.rs b/crates/shared/src/ids.rs index 7ab727c..426c6a3 100644 --- a/crates/shared/src/ids.rs +++ b/crates/shared/src/ids.rs @@ -52,3 +52,4 @@ id_type!(ExecutionId); id_type!(RequestId); id_type!(AdminUserId); id_type!(AppId); +id_type!(ApiKeyId); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 7fc4c67..53b864a 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -5,6 +5,7 @@ //! entity, error roots, transport DTOs). pub mod app; +pub mod auth; pub mod error; pub mod execution_log; pub mod ids; @@ -16,9 +17,10 @@ pub mod validator; pub mod version; pub use app::{App, AppDomain, DomainShape}; +pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use error::Error; pub use execution_log::{ExecutionLog, ExecutionStatus}; -pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId}; +pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId}; pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use route::{HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox;