feat(manager-core): add authz module with can() / require()
Implements the three-layer capability check from blueprint §11.6: role grant (instance role + app_members) ∩ scope intersection (for API keys) ∩ app binding (for bound keys). Capabilities are finer than scopes (AppWriteScript vs AppWriteRoute, AppManageDomains vs AppAdmin) so a script:write-only key cannot mutate routes; scopes stay at the seven values the blueprint locks down. In-memory AuthzRepo fixture in the test module covers the full matrix: owner / admin / member behavior, scope intersection, bound key isolation, and instance:* denial on bound keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
555
crates/manager-core/src/authz.rs
Normal file
555
crates/manager-core/src/authz.rs
Normal file
@@ -0,0 +1,555 @@
|
||||
//! 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),
|
||||
}
|
||||
|
||||
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) => 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(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_) => 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 `editor` on every app (per blueprint §11.6). They
|
||||
/// can create apps and manage users, but NOT touch instance-wide
|
||||
/// settings or take app-admin-only actions on apps they're not
|
||||
/// explicitly app_admin of. Everything not in this set falls through
|
||||
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
||||
const fn admin_grants(cap: Capability) -> bool {
|
||||
matches!(
|
||||
cap,
|
||||
Capability::InstanceCreateApp
|
||||
| Capability::InstanceManageUsers
|
||||
| Capability::AppRead(_)
|
||||
| Capability::AppWriteScript(_)
|
||||
| Capability::AppWriteRoute(_)
|
||||
| Capability::AppLogRead(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(_));
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
|
||||
);
|
||||
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_or_app_admin_actions() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
let app = AppId::new();
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageUsers).await.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageSettings).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
// Editor-like grants succeed
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteRoute(app)).await.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
// App-admin grants do not
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppManageDomains(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod log_sink;
|
||||
pub mod migrations;
|
||||
pub mod repo;
|
||||
@@ -43,6 +44,7 @@ pub use auth_bootstrap::{
|
||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||
};
|
||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
|
||||
Reference in New Issue
Block a user