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:
238
crates/shared/src/auth.rs
Normal file
238
crates/shared/src/auth.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -52,3 +52,4 @@ id_type!(ExecutionId);
|
||||
id_type!(RequestId);
|
||||
id_type!(AdminUserId);
|
||||
id_type!(AppId);
|
||||
id_type!(ApiKeyId);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user