Files
PiCloud/crates/manager-core/src/authz.rs
MechaCat02 06678f4496 feat(v1.1.2-docs): manager-core docs service + repo + query DSL parser
DocsServiceImpl mirrors KvServiceImpl's script-as-gate authz pattern,
the empty-collection rejection, and the best-effort emitter call —
adding "data must be a JSON object" validation, NotFound on update of
a missing doc, and prev_data plumbing via repo.update returning the
prior data.

PostgresDocsRepo handles CRUD against the docs table. The find path
runs through the v1.1.2 query DSL parser (docs_filter::parse_filter)
before building parameterised SQL via sqlx::QueryBuilder:

  * Every field-path segment + comparison value is bound as $N.
  * jsonb_extract_path_text(data, $N1, $N2, ...) handles variable
    depth without segment interpolation.
  * Base WHERE is fixed: WHERE app_id = $1 AND collection = $2.
    Filter conditions can only narrow, never widen. Load-bearing
    test in sql_shape_tests pins this prefix on every emitted query
    + asserts no user string ever lands in the SQL text.
  * $ne uses IS DISTINCT FROM (not <>) so missing paths + JSON nulls
    are correctly included.
  * $in binds the value list as TEXT[] via = ANY($N::text[]).
  * $sort always appends a ", id ASC" tiebreaker for stable cursor
    pagination semantics; $limit is clamped to MAX_FIND_LIMIT.

docs_filter is the AST + parser for the DSL. Operator allowlist is
explicit; any non-v1.1.2 operator throws UnsupportedOperator with a
v1.2 pointer. Snapshot tests pin the SDK-contract error strings so
changing them is a deliberate act.

Two new Capability variants — AppDocsRead and AppDocsWrite — map to
the existing Scope::ScriptRead and ScriptWrite per the seven-scope
commitment from v1.1.0. role_satisfies grants read at Viewer,
write at Editor (same trust shape as KV).

59 unit tests added across the three new files. All pass.

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

660 lines
23 KiB
Rust

//! Capability-based authorization — see blueprint §11.6.
//!
//! Single entry point for every admin endpoint: `can(repo, principal,
//! capability)` returns whether the caller can perform the action.
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
//! after loading the resource so the capability binds to the resource's
//! actual `app_id`, not a path param the caller controls.
//!
//! Three layers of intersection, evaluated in order:
//!
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
//! `app_members` row authorize this capability?
//! 2. **Scope intersection** — if the principal came from an API key
//! (`principal.scopes.is_some()`), does the key's scope set cover
//! the capability's required scope?
//! 3. **App binding** — if the key was minted bound to a specific
//! app (`principal.app_binding`), does the capability target the
//! same app? (Instance-level capabilities are denied for bound
//! keys; the mint handler also rejects the combination upfront.)
//!
//! The capability set is intentionally finer-grained than the seven
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
//! the `script:write` / `route:write` scopes respectively). Keeping
//! capabilities precise lets a `script:write`-only key write scripts
//! without also being able to mutate routes. The scope set stays at
//! seven values — capabilities are the internal check, scopes are the
//! external user-facing label.
use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
/// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute
/// it from the loaded resource (e.g., `script.app_id`), not from a
/// path param.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
/// Create a new app. Owner / admin only.
InstanceCreateApp,
/// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin.
InstanceManageUsers,
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
/// Owner only.
InstanceManageSettings,
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
/// (member); implicit for admin / owner.
AppRead(AppId),
/// Create / update / delete a script in this app.
AppWriteScript(AppId),
/// Create / update / delete a route in this app.
AppWriteRoute(AppId),
/// Manage domain claims on this app (add / remove).
AppManageDomains(AppId),
/// App settings + delete app. app_admin only (or owner via
/// implicit grant).
AppAdmin(AppId),
/// Read execution logs for scripts in this app.
AppLogRead(AppId),
/// Read entries from this app's KV store (v1.1.1). Granted to
/// `viewer`+ in the per-app role table. Maps to `script:read` on
/// API keys — the seven-scope vocabulary stays locked.
AppKvRead(AppId),
/// Write entries to this app's KV store (v1.1.1). Granted to
/// `editor`+. Maps to `script:write` on API keys.
AppKvWrite(AppId),
/// Read documents from this app's docs store (v1.1.2). Same trust
/// shape as KV read — granted to `viewer`+, maps to `script:read`
/// on API keys. Honors the seven-scope commitment.
AppDocsRead(AppId),
/// Write documents to this app's docs store (v1.1.2). Same trust
/// shape as KV write — granted to `editor`+, maps to
/// `script:write` on API keys.
AppDocsWrite(AppId),
/// Create / list / delete triggers for this app (v1.1.1). Maps to
/// `app:admin` on API keys — triggers are app-configuration acts
/// rather than data-plane access. Granted to `app_admin`+.
AppManageTriggers(AppId),
/// Replay / resolve dead-letter rows for this app (v1.1.1). Maps
/// to `app:admin` on API keys. Public-HTTP scripts (principal None)
/// fail this check — managing dead letters is an admin act.
AppDeadLetterManage(AppId),
}
impl Capability {
/// Extract the `AppId` for app-scoped capabilities; `None` for
/// instance-scoped ones. Used by the app-binding check on API keys.
#[must_use]
pub const fn app_id(self) -> Option<AppId> {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
None
}
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
| Self::AppManageDomains(id)
| Self::AppAdmin(id)
| Self::AppLogRead(id)
| Self::AppKvRead(id)
| Self::AppKvWrite(id)
| Self::AppDocsRead(id)
| Self::AppDocsWrite(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id) => Some(id),
}
}
/// The single scope that authorizes this capability on an API key.
/// Strict mapping — a `script:write` key cannot read scripts unless
/// it also carries `script:read`. The intent is predictability: a
/// key has exactly the scopes it was minted with, no implicit
/// upgrades.
#[must_use]
pub const fn required_scope(self) -> Scope {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) => {
Scope::ScriptWrite
}
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
Scope::AppAdmin
}
Self::AppLogRead(_) => Scope::LogRead,
}
}
}
/// Repo seam for membership lookups. Implemented in the DB-backed
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
/// means unit tests can stub it.
#[async_trait]
pub trait AuthzRepo: Send + Sync {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>;
}
/// Repo errors surface here so handlers can map them to 500 without
/// dragging sqlx types across the boundary.
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
#[error("authorization repo error: {0}")]
Repo(String),
}
/// Decision flavor returned by `can` — distinguishes outright denial
/// from a partial answer that requires further checks (none today,
/// but the shape lets us add audit/explain mode later without rewriting
/// every caller).
#[must_use = "an authorization decision must be acted on"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
}
impl Decision {
#[must_use]
pub const fn is_allow(self) -> bool {
matches!(self, Self::Allow)
}
}
/// Core authorization check. Walks the three intersection layers in
/// order and returns the resulting `Decision`.
pub async fn can(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<Decision, AuthzError> {
if !role_grants(repo, principal, cap).await? {
return Ok(Decision::Deny);
}
if !scope_allows(principal, cap) {
return Ok(Decision::Deny);
}
if !binding_allows(principal, cap) {
return Ok(Decision::Deny);
}
Ok(Decision::Allow)
}
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
/// Handlers call this so the `?` operator threads the 403 through
/// naturally.
///
/// # Errors
///
/// Returns `AuthzDenied::Denied` when the capability is not granted,
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
pub async fn require(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<(), AuthzDenied> {
match can(repo, principal, cap).await {
Ok(Decision::Allow) => Ok(()),
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
Err(e) => Err(AuthzDenied::Repo(e)),
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthzDenied {
#[error("forbidden")]
Denied,
#[error(transparent)]
Repo(#[from] AuthzError),
}
// ----------------------------------------------------------------------------
// Layer 1: role-derived grant
// ----------------------------------------------------------------------------
async fn role_grants(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<bool, AuthzError> {
match principal.instance_role {
InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
}
}
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
/// explicit `app_members` row with sufficient `AppRole`.
async fn member_grants(
repo: &dyn AuthzRepo,
user_id: UserId,
cap: Capability,
) -> Result<bool, AuthzError> {
let Some(app_id) = cap.app_id() else {
return Ok(false);
};
let Some(role) = repo.membership(user_id, app_id).await? else {
return Ok(false);
};
Ok(role_satisfies(role, cap))
}
/// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so
/// the check is "is this capability in the role's set?".
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
let in_viewer = matches!(
cap,
Capability::AppRead(_)
| Capability::AppLogRead(_)
| Capability::AppKvRead(_)
| Capability::AppDocsRead(_)
);
let in_editor = in_viewer
|| matches!(
cap,
Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppKvWrite(_)
| Capability::AppDocsWrite(_)
);
let in_app_admin = in_editor
|| matches!(
cap,
Capability::AppManageDomains(_)
| Capability::AppAdmin(_)
| Capability::AppManageTriggers(_)
| Capability::AppDeadLetterManage(_)
);
match role {
AppRole::Viewer => in_viewer,
AppRole::Editor => in_editor,
AppRole::AppAdmin => in_app_admin,
}
}
// ----------------------------------------------------------------------------
// Layer 2: API-key scope intersection
// ----------------------------------------------------------------------------
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
match &principal.scopes {
None => true, // cookie session — full role authority
Some(scopes) => scopes.contains(&cap.required_scope()),
}
}
// ----------------------------------------------------------------------------
// Layer 3: API-key app binding
// ----------------------------------------------------------------------------
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
let Some(bound_app) = principal.app_binding else {
return true;
};
match cap.app_id() {
// Instance-scoped capability + bound key → always denied. The
// mint handler also rejects this combination upfront, but
// defending in depth here means a stale/malformed row can't
// escalate.
None => false,
Some(target_app) => target_app == bound_app,
}
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::{AdminUserId, AppId};
use std::collections::HashMap;
use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database.
#[derive(Default)]
struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
}
impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role);
}
}
#[async_trait]
impl AuthzRepo for InMemoryAuthzRepo {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(self
.memberships
.lock()
.await
.get(&(user_id, app_id))
.copied())
}
}
fn principal(role: InstanceRole) -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: role,
scopes: None,
app_binding: None,
}
}
#[tokio::test]
async fn owner_can_do_everything() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Owner);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"owner denied {cap:?}"
);
}
}
#[tokio::test]
async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageUsers)
.await
.unwrap(),
Decision::Allow,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"admin denied app-scoped capability {cap:?}"
);
}
}
#[tokio::test]
async fn member_without_row_is_denied_everywhere() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Deny,
"member granted {cap:?} without a membership row"
);
}
}
#[tokio::test]
async fn member_with_viewer_role_can_read_but_not_write() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Viewer).await;
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppLogRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn member_with_editor_role_can_write_scripts_and_routes() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
assert!(can(&repo, &p, Capability::AppAdmin(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap()
.is_allow());
// Membership in App A does NOT grant access to App B
let other_app = AppId::new();
assert_eq!(
can(&repo, &p, Capability::AppAdmin(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn scoped_key_intersects_with_role() {
let repo = InMemoryAuthzRepo::default();
let app = AppId::new();
// Owner key with only script:read — cannot write
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptRead]),
app_binding: None,
};
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
// Even though the user is owner — the key's scope set is the
// hard ceiling.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_escape_its_app() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let other_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptWrite]),
app_binding: Some(bound_app),
};
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_do_instance_actions() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
app_binding: Some(bound_app),
};
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Deny,
"bound key with instance scope must still be denied at the binding layer"
);
}
#[test]
fn capability_app_id_extraction() {
let app = AppId::new();
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
}
#[test]
fn capability_required_scope_mapping_is_complete() {
// Sanity: every variant returns a scope. Compiler-enforced
// exhaustiveness lives in the match itself; this test guards
// against accidental drift to a default branch.
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
let _ = cap.required_scope(); // does not panic
}
}
}