feat(shared): add Principal, InstanceRole, AppRole, Scope, ApiKeyId

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 21:35:25 +02:00
parent d435322f9c
commit fd6f2b1f13
3 changed files with 242 additions and 1 deletions

238
crates/shared/src/auth.rs Normal file
View File

@@ -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<Self> {
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<Self> {
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> {
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<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
impl Serialize for Scope {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Scope {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<Vec<Scope>>,
/// `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<AppId>,
}
#[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::<Scope>("\"unknown\"").unwrap_err();
assert!(err.to_string().contains("unknown scope"));
}
}

View File

@@ -52,3 +52,4 @@ id_type!(ExecutionId);
id_type!(RequestId); id_type!(RequestId);
id_type!(AdminUserId); id_type!(AdminUserId);
id_type!(AppId); id_type!(AppId);
id_type!(ApiKeyId);

View File

@@ -5,6 +5,7 @@
//! entity, error roots, transport DTOs). //! entity, error roots, transport DTOs).
pub mod app; pub mod app;
pub mod auth;
pub mod error; pub mod error;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
@@ -16,9 +17,10 @@ pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape}; pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error; pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus}; 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 log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;