Files
PiCloud/crates/shared/src/auth.rs
MechaCat02 a432091191 feat(groups): tree repos, hierarchy-aware RBAC, admin API
Server-side foundation for Phase-2 groups (no group-owned resources yet):

Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
  folding the highest effective role across the membership chain.

Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
  coarse instance-wide structural advisory lock; slug frozen; bumps
  structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.

Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
  direct membership / none, so the ~18 existing test stubs are untouched);
  the Postgres impl resolves each via one depth-bounded recursive CTE that
  MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
  on any ancestor is implicitly app_admin beneath it. New Capability
  variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
  no app_id (bound API keys can't manage groups). 8 new unit tests.

Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
  parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
  responses carry group_id; my_role now reflects the effective role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:59:00 +02:00

267 lines
8.8 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,
}
}
/// Authority rank: higher = more authority. Used to fold the highest
/// effective role across an app's own membership and every ancestor
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
/// than via a derived `Ord` because the variant declaration order
/// (`AppAdmin` first) is the reverse of authority order.
#[must_use]
pub const fn precedence(self) -> u8 {
match self {
Self::AppAdmin => 3,
Self::Editor => 2,
Self::Viewer => 1,
}
}
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
#[must_use]
pub fn max(self, other: Self) -> Self {
if self.precedence() >= other.precedence() {
self
} else {
other
}
}
}
/// 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"));
}
}