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>
239 lines
8.0 KiB
Rust
239 lines
8.0 KiB
Rust
//! 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"));
|
|
}
|
|
}
|