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>
1224 lines
45 KiB
Rust
1224 lines
45 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, GroupId, 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 a new group (root-level). Owner / admin only — a Member
|
|
/// creates subgroups under a group they group-admin (gated by
|
|
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
|
InstanceCreateGroup,
|
|
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
|
/// group (inherited from any ancestor); implicit for admin / owner.
|
|
GroupRead(GroupId),
|
|
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
|
/// group.
|
|
GroupWrite(GroupId),
|
|
/// Group settings: delete, reparent, manage group members. group_admin
|
|
/// on the group (inherited from any ancestor).
|
|
GroupAdmin(GroupId),
|
|
/// 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),
|
|
/// Make an outbound HTTP request from a script in this app
|
|
/// (v1.1.4). Maps to `script:write` on API keys: any outbound
|
|
/// request can exfiltrate data — including read methods like GET —
|
|
/// so the conservative write mapping is correct. Splitting
|
|
/// read/write is a v1.2+ refinement. Granted to `editor`+.
|
|
AppHttpRequest(AppId),
|
|
/// Read blobs from this app's files store (v1.1.5). Same trust
|
|
/// shape as KV/docs read — granted to `viewer`+, maps to
|
|
/// `script:read` on API keys. Honors the seven-scope commitment.
|
|
AppFilesRead(AppId),
|
|
/// Write blobs to this app's files store (v1.1.5). Granted to
|
|
/// `editor`+, maps to `script:write` on API keys.
|
|
AppFilesWrite(AppId),
|
|
/// Publish a durable pub/sub message from a script in this app
|
|
/// (v1.1.5). Maps to `script:write` on API keys (a publish is a
|
|
/// write that fans out to subscribers). Granted to `editor`+.
|
|
AppPubsubPublish(AppId),
|
|
/// Enqueue a message onto this app's queue from a script (v1.1.9).
|
|
/// Maps to `script:write` on API keys (an enqueue is a write that
|
|
/// fans out to the registered consumer). Granted to `editor`+.
|
|
/// `depth` / `depth_pending` are read-only inspection and don't gate
|
|
/// — scripts in the app can always see their own queue depths.
|
|
AppQueueEnqueue(AppId),
|
|
/// Read a decrypted secret from this app's secrets store (v1.1.7).
|
|
/// Same trust shape as KV/docs/files read — granted to `viewer`+,
|
|
/// maps to `script:read` on API keys. Honors the seven-scope
|
|
/// commitment.
|
|
AppSecretsRead(AppId),
|
|
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
|
/// Granted to `editor`+, maps to `script:write` on API keys.
|
|
AppSecretsWrite(AppId),
|
|
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
|
/// to `script:write` on API keys (sending mail is an outbound
|
|
/// side-effect like an HTTP request). Granted to `editor`+.
|
|
AppEmailSend(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),
|
|
/// Register / list / update / delete externally-subscribable topics
|
|
/// for this app (v1.1.6). Maps to `app:admin` on API keys —
|
|
/// externalizing a topic is an app-configuration act with security
|
|
/// weight (it opens an internal pub/sub topic to outside SSE
|
|
/// subscribers). Granted to `app_admin`+.
|
|
AppTopicManage(AppId),
|
|
/// Read app-user records (v1.1.8 `users::*`) — `get`,
|
|
/// `find_by_email`, `list`, `verify`, `has_role`. Same trust shape
|
|
/// as KV/docs/files read — granted to `viewer`+, maps to
|
|
/// `script:read` on API keys. Honors the seven-scope commitment.
|
|
AppUsersRead(AppId),
|
|
/// Write app-user records (v1.1.8 `users::*`) — `create`, `update`,
|
|
/// `delete`, role mutations, login/logout, password reset, email
|
|
/// verification, invitation acceptance. Granted to `editor`+, maps
|
|
/// to `script:write` on API keys.
|
|
AppUsersWrite(AppId),
|
|
/// Admin-tier app-user actions (v1.1.8) — issuing invitations,
|
|
/// admin-mediated reset-password / revoke-sessions HTTP endpoints.
|
|
/// Maps to `script:write` on API keys (no new scope per the
|
|
/// seven-scope commitment); the additional gate vs `Write` lives in
|
|
/// the per-app role chain (`app_admin`+ only).
|
|
AppUsersAdmin(AppId),
|
|
/// F-S-012 (v1.1.9+): `invoke()` / `invoke_async()` synchronously
|
|
/// trigger another script in the same app. Same-app isolation is
|
|
/// already enforced (cross-app calls are rejected), but within one
|
|
/// app an anonymous public-HTTP script could otherwise trigger any
|
|
/// other script — including ones that hold capabilities the
|
|
/// original caller shouldn't. Gate authenticated callers on
|
|
/// AppInvoke; anonymous callers continue to skip the check under
|
|
/// the script-as-gate convention.
|
|
AppInvoke(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
|
|
| Self::InstanceCreateGroup
|
|
// Group-scoped caps carry a GroupId, not an AppId. They return
|
|
// None here so a bound API key (which can only target its one
|
|
// app) is denied group management at the binding layer.
|
|
| Self::GroupRead(_)
|
|
| Self::GroupWrite(_)
|
|
| Self::GroupAdmin(_) => 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::AppHttpRequest(id)
|
|
| Self::AppFilesRead(id)
|
|
| Self::AppFilesWrite(id)
|
|
| Self::AppPubsubPublish(id)
|
|
| Self::AppQueueEnqueue(id)
|
|
| Self::AppSecretsRead(id)
|
|
| Self::AppSecretsWrite(id)
|
|
| Self::AppEmailSend(id)
|
|
| Self::AppManageTriggers(id)
|
|
| Self::AppDeadLetterManage(id)
|
|
| Self::AppTopicManage(id)
|
|
| Self::AppUsersRead(id)
|
|
| Self::AppUsersWrite(id)
|
|
| Self::AppUsersAdmin(id)
|
|
| Self::AppInvoke(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
|
|
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
|
Self::AppRead(_)
|
|
| Self::AppKvRead(_)
|
|
| Self::AppDocsRead(_)
|
|
| Self::AppFilesRead(_)
|
|
| Self::AppSecretsRead(_)
|
|
| Self::AppUsersRead(_)
|
|
| Self::GroupRead(_) => Scope::ScriptRead,
|
|
Self::AppWriteScript(_)
|
|
| Self::AppKvWrite(_)
|
|
| Self::AppDocsWrite(_)
|
|
| Self::AppHttpRequest(_)
|
|
| Self::AppFilesWrite(_)
|
|
| Self::AppPubsubPublish(_)
|
|
| Self::AppQueueEnqueue(_)
|
|
| Self::AppSecretsWrite(_)
|
|
| Self::AppEmailSend(_)
|
|
| Self::AppUsersWrite(_)
|
|
| Self::AppUsersAdmin(_)
|
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
|
Self::AppAdmin(_)
|
|
| Self::AppManageTriggers(_)
|
|
| Self::AppDeadLetterManage(_)
|
|
| Self::AppTopicManage(_)
|
|
| Self::GroupWrite(_)
|
|
| Self::GroupAdmin(_) => 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 {
|
|
/// Direct `app_members` row for (user, app). The single-row lookup
|
|
/// used by member-management surfaces and as the fallback below.
|
|
async fn membership(
|
|
&self,
|
|
user_id: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError>;
|
|
|
|
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
|
/// the app's own `app_members` row folded with every `group_members`
|
|
/// row on any ancestor group, max-by-authority. This is what `can()`
|
|
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
|
/// on the app.
|
|
///
|
|
/// Default = direct membership only (no inheritance), so the many test
|
|
/// stubs that model no group tree keep their existing behavior; the
|
|
/// Postgres repo overrides this with an ancestor-walking CTE.
|
|
async fn effective_app_role(
|
|
&self,
|
|
user_id: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
self.membership(user_id, app_id).await
|
|
}
|
|
|
|
/// Highest effective role on a *group* node — the group's own
|
|
/// ancestor walk over `group_members`. Gates the group-management
|
|
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
|
async fn effective_group_role(
|
|
&self,
|
|
_user_id: UserId,
|
|
_group_id: GroupId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
}
|
|
|
|
/// Script-as-gate authz: anonymous public-HTTP scripts skip the check
|
|
/// (`cx.principal` is `None`); authenticated callers must hold `cap`.
|
|
///
|
|
/// Replaces the open-coded
|
|
/// `if let Some(p) = cx.principal { authz::require(...).await.map_err(...)? }`
|
|
/// pattern across every stateful service. `forbidden` is called when
|
|
/// the membership lookup returns `Denied`; `backend` is called when the
|
|
/// underlying repo errors. Both closures map to the caller's service-
|
|
/// specific error enum.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns the result of `forbidden(())` on `AuthzDenied::Denied`, or
|
|
/// `backend(repo_err.to_string())` on `AuthzDenied::Repo(repo_err)`.
|
|
pub async fn script_gate<E>(
|
|
repo: &dyn AuthzRepo,
|
|
cx: &picloud_shared::SdkCallCx,
|
|
cap: Capability,
|
|
forbidden: impl FnOnce() -> E,
|
|
backend: impl FnOnce(String) -> E,
|
|
) -> Result<(), E> {
|
|
let Some(principal) = cx.principal.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
match require(repo, principal, cap).await {
|
|
Ok(()) => Ok(()),
|
|
Err(AuthzDenied::Denied) => Err(forbidden()),
|
|
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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 => match cap {
|
|
// Group-management caps resolve against the group ancestor
|
|
// walk (a group_admin on an ancestor is implicitly admin of
|
|
// the descendant group). Routed before member_grants because
|
|
// group caps carry no app_id.
|
|
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
|
group_member_grants(repo, principal.user_id, cap, g).await
|
|
}
|
|
// Creating a root-level group is an instance act — members
|
|
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
|
// the handler, which routes through the arm above.)
|
|
Capability::InstanceCreateGroup => Ok(false),
|
|
_ => 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);
|
|
};
|
|
// Effective (inherited) role: the app's own membership folded with any
|
|
// ancestor group membership. A group_admin on an ancestor group is
|
|
// implicitly app_admin here.
|
|
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
|
return Ok(false);
|
|
};
|
|
Ok(role_satisfies(role, cap))
|
|
}
|
|
|
|
/// Member-path resolution for the group-management capabilities. Resolves
|
|
/// the caller's effective role on the group (ancestor walk over
|
|
/// `group_members`) and checks it covers the requested group action.
|
|
async fn group_member_grants(
|
|
repo: &dyn AuthzRepo,
|
|
user_id: UserId,
|
|
cap: Capability,
|
|
group_id: GroupId,
|
|
) -> Result<bool, AuthzError> {
|
|
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
|
return Ok(false);
|
|
};
|
|
Ok(group_role_satisfies(role, cap))
|
|
}
|
|
|
|
/// Does the effective group `AppRole` cover the group capability?
|
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|
match cap {
|
|
Capability::GroupRead(_) => true, // any role can read
|
|
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
|
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// 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(_)
|
|
| Capability::AppFilesRead(_)
|
|
| Capability::AppSecretsRead(_)
|
|
| Capability::AppUsersRead(_)
|
|
);
|
|
let in_editor = in_viewer
|
|
|| matches!(
|
|
cap,
|
|
Capability::AppWriteScript(_)
|
|
| Capability::AppWriteRoute(_)
|
|
| Capability::AppKvWrite(_)
|
|
| Capability::AppDocsWrite(_)
|
|
| Capability::AppHttpRequest(_)
|
|
| Capability::AppFilesWrite(_)
|
|
| Capability::AppPubsubPublish(_)
|
|
| Capability::AppQueueEnqueue(_)
|
|
| Capability::AppSecretsWrite(_)
|
|
| Capability::AppEmailSend(_)
|
|
| Capability::AppUsersWrite(_)
|
|
| Capability::AppInvoke(_)
|
|
);
|
|
let in_app_admin = in_editor
|
|
|| matches!(
|
|
cap,
|
|
Capability::AppManageDomains(_)
|
|
| Capability::AppAdmin(_)
|
|
| Capability::AppManageTriggers(_)
|
|
| Capability::AppDeadLetterManage(_)
|
|
| Capability::AppTopicManage(_)
|
|
| Capability::AppUsersAdmin(_)
|
|
);
|
|
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. Models
|
|
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
|
/// and group memberships, so the hierarchy-aware resolution can be
|
|
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
|
#[derive(Default)]
|
|
struct InMemoryAuthzRepo {
|
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
|
app_group: Mutex<HashMap<AppId, GroupId>>,
|
|
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
|
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
|
}
|
|
|
|
impl InMemoryAuthzRepo {
|
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
|
self.memberships.lock().await.insert((user, app), role);
|
|
}
|
|
/// Register a group node and its parent (`None` = root).
|
|
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
|
self.group_parent.lock().await.insert(group, parent);
|
|
}
|
|
/// Place an app under a group.
|
|
async fn put_app(&self, app: AppId, group: GroupId) {
|
|
self.app_group.lock().await.insert(app, group);
|
|
}
|
|
/// Grant a group-level role.
|
|
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
|
self.group_memberships
|
|
.lock()
|
|
.await
|
|
.insert((user, group), role);
|
|
}
|
|
|
|
/// Fold every ancestor group membership starting at `group`,
|
|
/// max-by-authority, into `acc`.
|
|
async fn fold_group_chain(
|
|
&self,
|
|
user_id: UserId,
|
|
mut group: Option<GroupId>,
|
|
mut acc: Option<AppRole>,
|
|
) -> Option<AppRole> {
|
|
let memberships = self.group_memberships.lock().await;
|
|
let parents = self.group_parent.lock().await;
|
|
let mut hops = 0u32;
|
|
while let Some(g) = group {
|
|
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
|
acc = Some(acc.map_or(r, |a| a.max(r)));
|
|
}
|
|
hops += 1;
|
|
if hops > 64 {
|
|
break;
|
|
}
|
|
group = parents.get(&g).copied().flatten();
|
|
}
|
|
acc
|
|
}
|
|
}
|
|
|
|
#[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())
|
|
}
|
|
|
|
async fn effective_app_role(
|
|
&self,
|
|
user_id: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
let direct = self
|
|
.memberships
|
|
.lock()
|
|
.await
|
|
.get(&(user_id, app_id))
|
|
.copied();
|
|
let start = self.app_group.lock().await.get(&app_id).copied();
|
|
Ok(self.fold_group_chain(user_id, start, direct).await)
|
|
}
|
|
|
|
async fn effective_group_role(
|
|
&self,
|
|
user_id: UserId,
|
|
group_id: GroupId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
|
}
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn topic_manage_requires_app_admin() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let app = AppId::new();
|
|
// Maps to the app:admin scope, not a new one.
|
|
assert_eq!(
|
|
Capability::AppTopicManage(app).required_scope(),
|
|
Scope::AppAdmin
|
|
);
|
|
|
|
// Member with only Editor role cannot manage topics.
|
|
let p = principal(InstanceRole::Member);
|
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
|
assert_eq!(
|
|
can(&repo, &p, Capability::AppTopicManage(app))
|
|
.await
|
|
.unwrap(),
|
|
Decision::Deny,
|
|
);
|
|
|
|
// App-admin role can.
|
|
let admin = principal(InstanceRole::Member);
|
|
repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
|
|
assert!(can(&repo, &admin, Capability::AppTopicManage(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_users_admin_requires_app_admin_role() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let app = AppId::new();
|
|
// Per the seven-scope commitment, AppUsersAdmin maps to
|
|
// script:write (no new scope) — but the per-app role chain
|
|
// still gates it at app_admin+.
|
|
assert_eq!(
|
|
Capability::AppUsersAdmin(app).required_scope(),
|
|
Scope::ScriptWrite
|
|
);
|
|
|
|
// Member with only Editor role cannot administer users.
|
|
let p = principal(InstanceRole::Member);
|
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
|
assert_eq!(
|
|
can(&repo, &p, Capability::AppUsersAdmin(app))
|
|
.await
|
|
.unwrap(),
|
|
Decision::Deny,
|
|
);
|
|
|
|
// App-admin role can.
|
|
let admin = principal(InstanceRole::Member);
|
|
repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
|
|
assert!(can(&repo, &admin, Capability::AppUsersAdmin(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_users_read_and_write_follow_viewer_editor_chain() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let app = AppId::new();
|
|
let viewer = principal(InstanceRole::Member);
|
|
repo.grant(viewer.user_id, app, AppRole::Viewer).await;
|
|
assert!(can(&repo, &viewer, Capability::AppUsersRead(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
assert_eq!(
|
|
can(&repo, &viewer, Capability::AppUsersWrite(app))
|
|
.await
|
|
.unwrap(),
|
|
Decision::Deny,
|
|
);
|
|
|
|
let editor = principal(InstanceRole::Member);
|
|
repo.grant(editor.user_id, app, AppRole::Editor).await;
|
|
assert!(can(&repo, &editor, Capability::AppUsersWrite(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
assert_eq!(
|
|
can(&repo, &editor, Capability::AppUsersAdmin(app))
|
|
.await
|
|
.unwrap(),
|
|
Decision::Deny,
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Hierarchy-aware RBAC (Phase 2 groups)
|
|
// ------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let acme = GroupId::new();
|
|
let app = AppId::new();
|
|
repo.add_group(acme, None).await;
|
|
repo.put_app(app, acme).await;
|
|
|
|
let p = principal(InstanceRole::Member);
|
|
// No app_members row — authority comes purely from the group.
|
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
|
|
|
for cap in [
|
|
Capability::AppRead(app),
|
|
Capability::AppWriteScript(app),
|
|
Capability::AppAdmin(app),
|
|
] {
|
|
assert!(
|
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
|
"inherited group_admin denied {cap:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let acme = GroupId::new();
|
|
let app = AppId::new();
|
|
repo.add_group(acme, None).await;
|
|
repo.put_app(app, acme).await;
|
|
|
|
let p = principal(InstanceRole::Member);
|
|
// Direct viewer on the app, app_admin via the ancestor group:
|
|
// the higher (app_admin) wins.
|
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
|
|
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn group_role_inherits_down_a_multi_level_tree() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let root = GroupId::new();
|
|
let team = GroupId::new();
|
|
let app = AppId::new();
|
|
repo.add_group(root, None).await;
|
|
repo.add_group(team, Some(root)).await;
|
|
repo.put_app(app, team).await;
|
|
|
|
// Editor two levels up flows down to the app as editor.
|
|
let p = principal(InstanceRole::Member);
|
|
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
|
|
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
assert_eq!(
|
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
|
Decision::Deny,
|
|
"editor must not get app_admin"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn group_membership_grants_no_instance_capabilities() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let acme = GroupId::new();
|
|
repo.add_group(acme, None).await;
|
|
let p = principal(InstanceRole::Member);
|
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
|
|
|
for cap in [
|
|
Capability::InstanceCreateApp,
|
|
Capability::InstanceCreateGroup,
|
|
Capability::InstanceManageUsers,
|
|
] {
|
|
assert_eq!(
|
|
can(&repo, &p, cap).await.unwrap(),
|
|
Decision::Deny,
|
|
"group_admin must not grant instance cap {cap:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn group_admin_walks_ancestors_for_group_caps() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let root = GroupId::new();
|
|
let team = GroupId::new();
|
|
repo.add_group(root, None).await;
|
|
repo.add_group(team, Some(root)).await;
|
|
|
|
let p = principal(InstanceRole::Member);
|
|
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
|
|
|
// group_admin at root ⇒ admin of the descendant group.
|
|
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
|
.await
|
|
.unwrap()
|
|
.is_allow());
|
|
|
|
// An unrelated member gets nothing.
|
|
let outsider = principal(InstanceRole::Member);
|
|
assert_eq!(
|
|
can(&repo, &outsider, Capability::GroupRead(team))
|
|
.await
|
|
.unwrap(),
|
|
Decision::Deny
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_implicitly_manages_the_whole_group_tree() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let g = GroupId::new();
|
|
let p = principal(InstanceRole::Admin);
|
|
for cap in [
|
|
Capability::InstanceCreateGroup,
|
|
Capability::GroupRead(g),
|
|
Capability::GroupWrite(g),
|
|
Capability::GroupAdmin(g),
|
|
] {
|
|
assert!(
|
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
|
"admin denied group cap {cap:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bound_key_cannot_manage_groups() {
|
|
let repo = InMemoryAuthzRepo::default();
|
|
let g = GroupId::new();
|
|
let p = Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Owner,
|
|
scopes: Some(vec![Scope::AppAdmin]),
|
|
app_binding: Some(AppId::new()),
|
|
};
|
|
// Group caps carry no app_id, so a bound key is denied at the
|
|
// binding layer regardless of role/scope.
|
|
assert_eq!(
|
|
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
|
Decision::Deny
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_role_max_is_authority_ordered() {
|
|
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
|
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
|
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
|
}
|
|
|
|
#[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));
|
|
// Group caps are not app-scoped.
|
|
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
|
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|
|
}
|