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>
660 lines
23 KiB
Rust
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
|
|
}
|
|
}
|
|
}
|