feat(groups): tree repos, hierarchy-aware RBAC, admin API

Server-side foundation for Phase-2 groups (no group-owned resources yet):

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 19:59:00 +02:00
parent 2b27012f56
commit a432091191
16 changed files with 1826 additions and 53 deletions

View File

@@ -8,11 +8,17 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole}; use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
use sqlx::PgPool; use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo}; use crate::authz::{AuthzError, AuthzRepo};
/// SQL fragment ranking the three role literals by authority so a CTE can
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
/// both effective-role queries.
const ROLE_RANK_CTE: &str =
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AppMembersRepositoryError { pub enum AppMembersRepositoryError {
#[error("database error: {0}")] #[error("database error: {0}")]
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
impl AuthzRepo for PostgresAppMembersRepository { impl AuthzRepo for PostgresAppMembersRepository {
async fn membership( async fn membership(
&self, &self,
user_id: AdminUserId, user_id: UserId,
app_id: AppId, app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> { ) -> Result<Option<AppRole>, AuthzError> {
self.find(user_id, app_id) self.find(user_id, app_id)
.await .await
.map_err(|e| AuthzError::Repo(e.to_string())) .map_err(|e| AuthzError::Repo(e.to_string()))
} }
/// Highest effective role on `app_id`: one recursive CTE walks the
/// app's group chain (app.group_id → groups.parent_id → … → root,
/// depth-bounded), then `MAX`es the app's own `app_members` row with
/// every ancestor `group_members` row by authority rank. A single
/// round-trip regardless of tree depth. `None` = no grant anywhere.
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
let row: Option<(String,)> = sqlx::query_as(&format!(
"WITH RECURSIVE ancestors AS (
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
UNION ALL
SELECT g.parent_id, anc.depth + 1
FROM groups g JOIN ancestors anc ON g.id = anc.gid
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
),
{ROLE_RANK_CTE}
SELECT eff.role
FROM (
SELECT am.role FROM app_members am
WHERE am.user_id = $1 AND am.app_id = $2
UNION ALL
SELECT gm.role FROM group_members gm
JOIN ancestors anc ON anc.gid = gm.group_id
WHERE gm.user_id = $1
) eff
JOIN role_rank rr ON rr.role = eff.role
ORDER BY rr.rank DESC
LIMIT 1"
))
.bind(user_id.into_inner())
.bind(app_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
"invalid role {role:?} in members table"
)))
})
.transpose()
}
/// Highest effective role on a *group* node — walks the group's own
/// ancestor chain over `group_members`. Gates group management.
async fn effective_group_role(
&self,
user_id: UserId,
group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
let row: Option<(String,)> = sqlx::query_as(&format!(
"WITH RECURSIVE ancestors AS (
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
UNION ALL
SELECT g.id, g.parent_id, anc.depth + 1
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
WHERE anc.depth < 64
),
{ROLE_RANK_CTE}
SELECT gm.role
FROM group_members gm
JOIN ancestors anc ON anc.gid = gm.group_id
JOIN role_rank rr ON rr.role = gm.role
WHERE gm.user_id = $1
ORDER BY rr.rank DESC
LIMIT 1"
))
.bind(user_id.into_inner())
.bind(group_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
"invalid role {role:?} in group_members table"
)))
})
.transpose()
}
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]

View File

@@ -6,7 +6,7 @@
//! that writes the history row in the same transaction. //! that writes the history row in the same transaction.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{AdminUserId, App, AppId}; use picloud_shared::{AdminUserId, App, AppId, GroupId};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
/// Only apps the user has an `app_members` row for. Drives the /// Only apps the user has an `app_members` row for. Drives the
/// membership-filtered `GET /admin/apps` for `member` callers. /// membership-filtered `GET /admin/apps` for `member` callers.
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>; async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
/// Apps whose parent is `group_id`. Drives the group detail view.
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>; async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history( async fn get_by_slug_or_history(
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
slug: &str, slug: &str,
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError>; ) -> Result<App, ScriptRepositoryError>;
/// Create that also consumes a matching `app_slug_history` row, if /// Create that also consumes a matching `app_slug_history` row, if
/// any. Used after the operator has confirmed they want to break old /// any. Used after the operator has confirmed they want to break old
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
slug: &str, slug: &str,
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError>; ) -> Result<App, ScriptRepositoryError>;
async fn update( async fn update(
&self, &self,
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
impl AppRepository for PostgresAppRepository { impl AppRepository for PostgresAppRepository {
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> { async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>( let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps ORDER BY name", FROM apps ORDER BY name",
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> { async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>( let rows = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \ "SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
FROM apps a \ FROM apps a \
JOIN app_members m ON m.app_id = a.id \ JOIN app_members m ON m.app_id = a.id \
WHERE m.user_id = $1 \ WHERE m.user_id = $1 \
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE group_id = $1 ORDER BY name",
)
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> { async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE id = $1", FROM apps WHERE id = $1",
) )
.bind(id.into_inner()) .bind(id.into_inner())
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> { async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE slug = $1", FROM apps WHERE slug = $1",
) )
.bind(slug) .bind(slug)
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> { async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \ "SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
FROM app_slug_history h \ FROM app_slug_history h \
JOIN apps a ON a.id = h.current_app_id \ JOIN apps a ON a.id = h.current_app_id \
WHERE h.slug = $1", WHERE h.slug = $1",
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
slug: &str, slug: &str,
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError> { ) -> Result<App, ScriptRepositoryError> {
let res = sqlx::query_as::<_, AppRow>( let res = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \ "INSERT INTO apps (slug, name, description, group_id) \
VALUES ($1, $2, $3) \ VALUES ($1, $2, $3, $4) \
RETURNING id, slug, name, description, created_at, updated_at", RETURNING id, slug, name, description, group_id, created_at, updated_at",
) )
.bind(slug) .bind(slug)
.bind(name) .bind(name)
.bind(description) .bind(description)
.bind(group_id.into_inner())
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await; .await;
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
slug: &str, slug: &str,
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError> { ) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1") sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \ "INSERT INTO apps (slug, name, description, group_id) \
VALUES ($1, $2, $3) \ VALUES ($1, $2, $3, $4) \
RETURNING id, slug, name, description, created_at, updated_at", RETURNING id, slug, name, description, group_id, created_at, updated_at",
) )
.bind(slug) .bind(slug)
.bind(name) .bind(name)
.bind(description) .bind(description)
.bind(group_id.into_inner())
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await; .await;
let row = match row { let row = match row {
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
description = CASE WHEN $3::bool THEN $4 ELSE description END, \ description = CASE WHEN $3::bool THEN $4 ELSE description END, \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at", RETURNING id, slug, name, description, group_id, created_at, updated_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.bind(name) .bind(name)
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
if current_slug == new_slug { if current_slug == new_slug {
// No-op rename; just return the row. // No-op rename; just return the row.
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE id = $1", FROM apps WHERE id = $1",
) )
.bind(id.into_inner()) .bind(id.into_inner())
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET slug = $2, updated_at = NOW() \ "UPDATE apps SET slug = $2, updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at", RETURNING id, slug, name, description, group_id, created_at, updated_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.bind(new_slug) .bind(new_slug)
@@ -432,6 +451,7 @@ struct AppRow {
slug: String, slug: String,
name: String, name: String,
description: Option<String>, description: Option<String>,
group_id: uuid::Uuid,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
} }
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
slug: r.slug, slug: r.slug,
name: r.name, name: r.name,
description: r.description, description: r.description,
group_id: r.group_id.into(),
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
} }

View File

@@ -25,6 +25,7 @@ use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain}; use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository; use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository; use crate::route_repo::RouteRepository;
@@ -44,6 +45,9 @@ pub struct AppsState {
pub domain_table: Arc<AppDomainTable>, pub domain_table: Arc<AppDomainTable>,
/// Capability gate — Phase 3.5. /// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>, pub authz: Arc<dyn AuthzRepo>,
/// Group tree — resolves an app's parent group at create time
/// (defaults to the instance root).
pub groups: Arc<dyn GroupRepository>,
} }
pub fn apps_router(state: AppsState) -> Router { pub fn apps_router(state: AppsState) -> Router {
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
pub slug: String, pub slug: String,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root group when
/// omitted — every app has a parent from day one (§9).
#[serde(default)]
pub group: Option<String>,
/// Set to `true` to consume an existing `app_slug_history` row for /// Set to `true` to consume an existing `app_slug_history` row for
/// the requested slug (breaking old redirects). /// the requested slug (breaking old redirects).
#[serde(default)] #[serde(default)]
@@ -176,23 +184,46 @@ async fn create_app(
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?; require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
validate_slug(&input.slug)?; validate_slug(&input.slug)?;
// Resolve the parent group: an explicit `group` (slug or id) or the
// instance root by default. Placing an app under a specific group
// additionally requires group-write there.
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
if input.group.is_some() {
require(
s.authz.as_ref(),
&principal,
Capability::GroupWrite(parent.id),
)
.await?;
}
// Historical-slug check before insert: if the slug is in history // Historical-slug check before insert: if the slug is in history
// and the caller hasn't asked to force takeover, surface a clean // and the caller hasn't asked to force takeover, surface a clean
// 409 so the dashboard can present a "this will break old links" // 409 so the dashboard can present a "this will break old links"
// confirmation. // confirmation.
if !input.force_takeover { if !input.force_takeover {
if let Some(current) = s.apps.slug_in_history(&input.slug).await? { if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
return Err(AppsApiError::SlugInHistory(current)); return Err(AppsApiError::SlugInHistory(Box::new(current)));
} }
} }
let created = if input.force_takeover { let created = if input.force_takeover {
s.apps s.apps
.create_with_takeover(&input.slug, &input.name, input.description.as_deref()) .create_with_takeover(
&input.slug,
&input.name,
input.description.as_deref(),
parent.id,
)
.await? .await?
} else { } else {
s.apps s.apps
.create(&input.slug, &input.name, input.description.as_deref()) .create(
&input.slug,
&input.name,
input.description.as_deref(),
parent.id,
)
.await? .await?
}; };
Ok((StatusCode::CREATED, Json(created))) Ok((StatusCode::CREATED, Json(created)))
@@ -235,7 +266,31 @@ async fn compute_my_role(
) -> Result<Option<AppRole>, AppsApiError> { ) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role { match principal.instance_role {
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)), InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?), // Effective role: folds in inherited group memberships so the
// dashboard badge reflects what the caller can actually do.
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
}
}
/// Resolve an optional group identifier (slug or UUID) to a group,
/// defaulting to the instance root group when `None`.
async fn resolve_group(
groups: &dyn GroupRepository,
ident: Option<&str>,
) -> Result<picloud_shared::Group, AppsApiError> {
match ident {
None => groups
.get_by_slug(ROOT_GROUP_SLUG)
.await?
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
Some(ident) => {
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
groups.get_by_id(uuid.into()).await?
} else {
groups.get_by_slug(ident).await?
};
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
}
} }
} }
@@ -279,7 +334,7 @@ async fn patch_app(
Ok(app) => app, Ok(app) => app,
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => { Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
if let Some(current) = s.apps.slug_in_history(new_slug).await? { if let Some(current) = s.apps.slug_in_history(new_slug).await? {
return Err(AppsApiError::SlugInHistory(current)); return Err(AppsApiError::SlugInHistory(Box::new(current)));
} }
return Err(AppsApiError::Conflict(msg)); return Err(AppsApiError::Conflict(msg));
} }
@@ -521,14 +576,19 @@ pub enum AppsApiError {
#[error("app not found: {0}")] #[error("app not found: {0}")]
AppNotFound(String), AppNotFound(String),
#[error("group not found: {0}")]
GroupNotFound(String),
#[error("domain not found: {0}")] #[error("domain not found: {0}")]
DomainNotFound(Uuid), DomainNotFound(Uuid),
#[error("invalid slug: {0}")] #[error("invalid slug: {0}")]
InvalidSlug(String), InvalidSlug(String),
// Boxed: `App` is large enough to trip clippy::result_large_err on
// every handler returning `Result<_, AppsApiError>`.
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")] #[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
SlugInHistory(App), SlugInHistory(Box<App>),
#[error("app still contains {0} script(s); delete or move them first")] #[error("app still contains {0} script(s); delete or move them first")]
HasScripts(i64), HasScripts(i64),
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
} }
} }
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
use crate::group_repo::GroupRepositoryError as G;
match e {
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
G::Conflict(msg) => Self::Conflict(msg),
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
}
}
}
impl IntoResponse for AppsApiError { impl IntoResponse for AppsApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
Self::AppNotFound(_) Self::AppNotFound(_)
| Self::GroupNotFound(_)
| Self::DomainNotFound(_) | Self::DomainNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => { | Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))

View File

@@ -27,7 +27,7 @@
//! external user-facing label. //! external user-facing label.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId}; use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
/// Things a caller can attempt to do. Each app-scoped variant carries /// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute /// the `AppId` of the resource the action targets — handlers compute
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
pub enum Capability { pub enum Capability {
/// Create a new app. Owner / admin only. /// Create a new app. Owner / admin only.
InstanceCreateApp, 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 /// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin. /// password change, which is a separate flow). Owner / admin.
InstanceManageUsers, InstanceManageUsers,
@@ -154,9 +167,16 @@ impl Capability {
#[must_use] #[must_use]
pub const fn app_id(self) -> Option<AppId> { pub const fn app_id(self) -> Option<AppId> {
match self { match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { Self::InstanceCreateApp
None | 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::AppRead(id)
| Self::AppWriteScript(id) | Self::AppWriteScript(id)
| Self::AppWriteRoute(id) | Self::AppWriteRoute(id)
@@ -193,15 +213,17 @@ impl Capability {
#[must_use] #[must_use]
pub const fn required_scope(self) -> Scope { pub const fn required_scope(self) -> Scope {
match self { match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { Self::InstanceCreateApp
Scope::InstanceAdmin | Self::InstanceManageUsers
} | Self::InstanceManageSettings
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
Self::AppRead(_) Self::AppRead(_)
| Self::AppKvRead(_) | Self::AppKvRead(_)
| Self::AppDocsRead(_) | Self::AppDocsRead(_)
| Self::AppFilesRead(_) | Self::AppFilesRead(_)
| Self::AppSecretsRead(_) | Self::AppSecretsRead(_)
| Self::AppUsersRead(_) => Scope::ScriptRead, | Self::AppUsersRead(_)
| Self::GroupRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) Self::AppWriteScript(_)
| Self::AppKvWrite(_) | Self::AppKvWrite(_)
| Self::AppDocsWrite(_) | Self::AppDocsWrite(_)
@@ -219,7 +241,9 @@ impl Capability {
Self::AppAdmin(_) Self::AppAdmin(_)
| Self::AppManageTriggers(_) | Self::AppManageTriggers(_)
| Self::AppDeadLetterManage(_) | Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_) => Scope::AppAdmin, | Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead, Self::AppLogRead(_) => Scope::LogRead,
} }
} }
@@ -230,11 +254,41 @@ impl Capability {
/// means unit tests can stub it. /// means unit tests can stub it.
#[async_trait] #[async_trait]
pub trait AuthzRepo: Send + Sync { 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( async fn membership(
&self, &self,
user_id: UserId, user_id: UserId,
app_id: AppId, app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>; ) -> 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 /// Repo errors surface here so handlers can map them to 500 without
@@ -353,7 +407,20 @@ async fn role_grants(
match principal.instance_role { match principal.instance_role {
InstanceRole::Owner => Ok(true), InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)), InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await, 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,
},
} }
} }
@@ -377,12 +444,41 @@ async fn member_grants(
let Some(app_id) = cap.app_id() else { let Some(app_id) = cap.app_id() else {
return Ok(false); return Ok(false);
}; };
let Some(role) = repo.membership(user_id, app_id).await? else { // 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); return Ok(false);
}; };
Ok(role_satisfies(role, cap)) 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; /// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings, /// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so /// domain claims, and delete. Roles form a strict subset chain, so
@@ -471,16 +567,61 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database. /// 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)] #[derive(Default)]
struct InMemoryAuthzRepo { struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>, 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 { impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) { async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role); 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] #[async_trait]
@@ -497,6 +638,29 @@ mod tests {
.get(&(user_id, app_id)) .get(&(user_id, app_id))
.copied()) .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 { fn principal(role: InstanceRole) -> Principal {
@@ -857,12 +1021,183 @@ mod tests {
); );
} }
// ------------------------------------------------------------------
// 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] #[test]
fn capability_app_id_extraction() { fn capability_app_id_extraction() {
let app = AppId::new(); let app = AppId::new();
assert_eq!(Capability::InstanceCreateApp.app_id(), None); assert_eq!(Capability::InstanceCreateApp.app_id(), None);
assert_eq!(Capability::AppRead(app).app_id(), Some(app)); assert_eq!(Capability::AppRead(app).app_id(), Some(app));
assert_eq!(Capability::AppAdmin(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] #[test]

View File

@@ -0,0 +1,205 @@
//! CRUD over the `group_members` table — explicit per-(user, group) role
//! grants. A `group_admin` here is implicitly app_admin on every app and
//! subgroup beneath the group; that inheritance is resolved by the authz
//! ancestor walk (`app_members_repo::effective_app_role`), not here.
//!
//! Mirrors `app_members_repo` — same three role literals, same shapes.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum GroupMembersRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid app_role stored in DB: {0}")]
InvalidRole(String),
}
#[derive(Debug, Clone)]
pub struct GroupMembershipRow {
pub group_id: GroupId,
pub user_id: AdminUserId,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
/// `group_members` row joined with `admin_users` for the dashboard's
/// per-group Members tab.
#[derive(Debug, Clone)]
pub struct GroupMembershipDetail {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[async_trait]
pub trait GroupMembersRepository: Send + Sync {
/// Atomic insert. `None` if a membership already exists (handler → 409).
async fn try_insert(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
/// Atomic role update. `None` if no row exists (handler → 404).
async fn update_role(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
/// Remove a membership. No-op when the row doesn't exist.
async fn remove(
&self,
group_id: GroupId,
user_id: AdminUserId,
) -> Result<(), GroupMembersRepositoryError>;
/// Per-group member list joined with `admin_users`, ordered by username.
async fn list_for_group_enriched(
&self,
group_id: GroupId,
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError>;
}
pub struct PostgresGroupMembersRepository {
pool: PgPool,
}
impl PostgresGroupMembersRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl GroupMembersRepository for PostgresGroupMembersRepository {
async fn try_insert(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
let row = sqlx::query_as::<_, GroupMembershipRecord>(
"INSERT INTO group_members (group_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (group_id, user_id) DO NOTHING \
RETURNING group_id, user_id, role, created_at",
)
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn update_role(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
let row = sqlx::query_as::<_, GroupMembershipRecord>(
"UPDATE group_members SET role = $1 \
WHERE group_id = $2 AND user_id = $3 \
RETURNING group_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn remove(
&self,
group_id: GroupId,
user_id: AdminUserId,
) -> Result<(), GroupMembersRepositoryError> {
sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2")
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_group_enriched(
&self,
group_id: GroupId,
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError> {
let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>(
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
gm.role, gm.created_at \
FROM group_members gm \
JOIN admin_users au ON au.id = gm.user_id \
WHERE gm.group_id = $1 \
ORDER BY au.username",
)
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
#[derive(sqlx::FromRow)]
struct GroupMembershipRecord {
group_id: uuid::Uuid,
user_id: uuid::Uuid,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<GroupMembershipRecord> for GroupMembershipRow {
type Error = GroupMembersRepositoryError;
fn try_from(r: GroupMembershipRecord) -> Result<Self, Self::Error> {
Ok(Self {
group_id: r.group_id.into(),
user_id: r.user_id.into(),
role: AppRole::from_db_str(&r.role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct GroupMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<GroupMembershipDetailRecord> for GroupMembershipDetail {
type Error = GroupMembersRepositoryError;
fn try_from(r: GroupMembershipDetailRecord) -> Result<Self, Self::Error> {
Ok(Self {
user_id: r.id.into(),
username: r.username,
email: r.email,
instance_role: InstanceRole::from_db_str(&r.instance_role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.instance_role))?,
is_active: r.is_active,
role: AppRole::from_db_str(&r.role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}

View File

@@ -0,0 +1,367 @@
//! CRUD over the `groups` tree (Phase 2).
//!
//! Groups form a single-parent org tree above apps. Structural mutations
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
//!
//! - **delete = RESTRICT** — refused if the group has child groups or apps
//! (the DB FKs enforce this; we surface a clean conflict).
//! - **slug-freeze** — `rename` edits name/description only; the slug is
//! set once at creation and never rewritten.
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
//! coarse instance-wide advisory lock and refuses a move that would make
//! a node its own ancestor. A SQL `CHECK` can't express this.
//! - **structure_version** — bumped on every structural mutation so a
//! future CLI/orchestrator can detect structural drift (§6).
use async_trait::async_trait;
use picloud_shared::{Group, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
/// Instance-wide advisory-lock key for structural group mutations. Coarse
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
/// cycle guard and the `parent_id` write run serialized — two concurrent
/// reparents can't race into a cycle. Distinct from the per-app
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
/// Well-known slug of the instance root group seeded by migration 0047.
pub const ROOT_GROUP_SLUG: &str = "root";
#[derive(Debug, thiserror::Error)]
pub enum GroupRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(GroupId),
#[error("conflict: {0}")]
Conflict(String),
}
/// Counts of a group's direct children — used to enforce delete=RESTRICT
/// with an actionable message and to render the tree.
#[derive(Debug, Clone, Copy)]
pub struct GroupChildCounts {
pub subgroups: i64,
pub apps: i64,
}
impl GroupChildCounts {
#[must_use]
pub fn is_empty(self) -> bool {
self.subgroups == 0 && self.apps == 0
}
}
#[async_trait]
pub trait GroupRepository: Send + Sync {
/// Every group on the instance, ordered by name. The tree is small
/// (org structure), so callers assemble the hierarchy in memory.
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
/// Direct children groups of `parent`.
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
/// The node plus its ancestors up to the root, nearest-first. Used for
/// path display and as the reparent cycle-guard input.
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError>;
/// Edit display fields only — the slug is frozen at creation. Bumps
/// `structure_version`.
async fn rename(
&self,
id: GroupId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<Group, GroupRepositoryError>;
/// Move `id` under `new_parent` (or to root if `None`). Runs the
/// ancestor-walk cycle guard under a coarse structural lock and bumps
/// `structure_version`. Refuses a move that would create a cycle.
async fn reparent(
&self,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError>;
/// Delete an empty group (delete = RESTRICT). Refused with a clean
/// conflict if it still has child groups or apps.
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
}
pub struct PostgresGroupRepository {
pool: PgPool,
}
impl PostgresGroupRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const GROUP_COLS: &str =
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
#[async_trait]
impl GroupRepository for PostgresGroupRepository {
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups ORDER BY name"
))
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
))
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
))
.bind(parent.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
// Recursive walk node → root, nearest-first. Depth-bounded as a
// runaway guard (the cycle guard already prevents cycles).
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"WITH RECURSIVE chain AS (
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
UNION ALL
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.parent_id \
WHERE c.depth < 64
)
SELECT {GROUP_COLS} FROM chain ORDER BY depth"
))
.bind(id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
let row: (i64, i64) = sqlx::query_as(
"SELECT \
(SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
)
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(GroupChildCounts {
subgroups: row.0,
apps: row.1,
})
}
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) \
RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
async fn rename(
&self,
id: GroupId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<Group, GroupRepositoryError> {
// Slug is intentionally absent from the SET list — it is frozen.
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(name)
.bind(description.is_some())
.bind(description.and_then(|d| d))
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
async fn reparent(
&self,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
// Coarse structural lock: serialize all structural mutations so the
// cycle guard + parent write can't interleave with a concurrent
// reparent and race into a cycle.
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx)
.await?;
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
tx.commit().await?;
Ok(row.into())
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
// Pre-check for a clean message; the FK RESTRICT is the real guard.
let counts = self.child_counts(id).await?;
if !counts.is_empty() {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.subgroups, counts.apps
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// Lost a race with a concurrent child insert.
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
}
#[derive(sqlx::FromRow)]
struct GroupRow {
id: Uuid,
parent_id: Option<Uuid>,
slug: String,
name: String,
description: Option<String>,
structure_version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<GroupRow> for Group {
fn from(r: GroupRow) -> Self {
Self {
id: r.id.into(),
parent_id: r.parent_id.map(Into::into),
slug: r.slug,
name: r.name,
description: r.description,
structure_version: r.structure_version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -0,0 +1,577 @@
//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
//! membership (Phase 2, blueprint §5).
//!
//! Group capabilities resolve by walking the group's ancestor chain
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
//! implicitly admin of every descendant group. Structural mutations
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
//! requires admin at BOTH the source and destination parent (§5.6).
//!
//! Slug is frozen at creation — PATCH edits name/description only.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch, post};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, App, AppRole, Group, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
};
use crate::group_repo::{GroupRepository, GroupRepositoryError};
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
];
#[derive(Clone)]
pub struct GroupsState {
pub groups: Arc<dyn GroupRepository>,
pub group_members: Arc<dyn GroupMembersRepository>,
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn groups_router(state: GroupsState) -> Router {
Router::new()
.route("/groups", get(list_groups).post(create_group))
.route(
"/groups/{id_or_slug}",
get(get_group).patch(patch_group).delete(delete_group),
)
.route("/groups/{id_or_slug}/reparent", post(reparent_group))
.route(
"/groups/{id_or_slug}/members",
get(list_members).post(grant_member),
)
.route(
"/groups/{id_or_slug}/members/{user_id}",
patch(patch_member).delete(remove_member),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct GroupDetailDto {
#[serde(flatten)]
pub group: Group,
/// Root → … → this group (nearest-last), for breadcrumb display.
pub path: Vec<Group>,
pub subgroups: Vec<Group>,
pub apps: Vec<App>,
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Parent group (slug or id). Omitted ⇒ a root-level group
/// (owner/admin only).
#[serde(default)]
pub parent: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PatchGroupRequest {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ReparentRequest {
/// New parent (slug or id). Omitted/null ⇒ move to root.
#[serde(default)]
pub parent: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GroupMemberDto {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
impl From<GroupMembershipDetail> for GroupMemberDto {
fn from(d: GroupMembershipDetail) -> Self {
Self {
user_id: d.user_id,
username: d.username,
email: d.email,
instance_role: d.instance_role,
is_active: d.is_active,
role: d.role,
created_at: d.created_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
pub user_id: AdminUserId,
pub role: AppRole,
}
#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
pub role: AppRole,
}
// ----------------------------------------------------------------------------
// Group CRUD handlers
// ----------------------------------------------------------------------------
/// List the whole tree. Phase-2 simplification: group names/structure are
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
/// any authenticated admin sees the full tree; per-action authz still
/// gates every mutation and all app access. Tighten in Phase 3 when groups
/// carry inheritable config.
async fn list_groups(
State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<Group>>, GroupsApiError> {
Ok(Json(s.groups.list().await?))
}
async fn create_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateGroupRequest>,
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
validate_slug(&input.slug)?;
let parent_id = match input.parent.as_deref() {
// Root-level group — an instance act.
None => {
require(
s.authz.as_ref(),
&principal,
Capability::InstanceCreateGroup,
)
.await?;
None
}
// Subgroup — requires group-admin at the parent.
Some(ident) => {
let parent = resolve_group(&*s.groups, ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(parent.id),
)
.await?;
Some(parent.id)
}
};
let created = s
.groups
.create(
&input.slug,
&input.name,
input.description.as_deref(),
parent_id,
)
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn get_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupRead(group.id),
)
.await?;
// ancestors() is nearest-first incl. the node; reverse for a
// root→…→node breadcrumb.
let mut path = s.groups.ancestors(group.id).await?;
path.reverse();
let subgroups = s.groups.list_children(group.id).await?;
let apps = s.apps.list_for_group(group.id).await?;
Ok(Json(GroupDetailDto {
group,
path,
subgroups,
apps,
}))
}
async fn patch_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<PatchGroupRequest>,
) -> Result<Json<Group>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupWrite(group.id),
)
.await?;
let updated = s
.groups
.rename(
group.id,
input.name.as_deref(),
input.description.as_ref().map(|d| Some(d.as_str())),
)
.await?;
Ok(Json(updated))
}
async fn reparent_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<ReparentRequest>,
) -> Result<Json<Group>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
// Admin of the node being moved.
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
// Admin at the SOURCE parent (you're removing it from that domain).
if let Some(src) = group.parent_id {
require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
}
// Resolve + require admin at the DESTINATION parent.
let new_parent = match input.parent.as_deref() {
None => None,
Some(ident) => {
let dest = resolve_group(&*s.groups, ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(dest.id),
)
.await?;
Some(dest.id)
}
};
let moved = s.groups.reparent(group.id, new_parent).await?;
Ok(Json(moved))
}
async fn delete_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<StatusCode, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
s.groups.delete(group.id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Member handlers — gated on GroupAdmin(group)
// ----------------------------------------------------------------------------
async fn list_members(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let rows = s.group_members.list_for_group_enriched(group.id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn grant_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let user = s
.users
.get(input.user_id)
.await?
.ok_or(GroupsApiError::UserNotFound(input.user_id))?;
validate_grant_target(&user)?;
let row = s
.group_members
.try_insert(group.id, user.id, input.role)
.await?
.ok_or_else(|| GroupsApiError::AlreadyMember {
username: user.username.clone(),
})?;
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}
async fn patch_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<PatchMemberRequest>,
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let user_id = AdminUserId::from(user_id);
let user = s
.users
.get(user_id)
.await?
.ok_or(GroupsApiError::UserNotFound(user_id))?;
let row = s
.group_members
.update_role(group.id, user_id, input.role)
.await?
.ok_or(GroupsApiError::MembershipNotFound)?;
Ok(Json(compose_dto(user, row)))
}
async fn remove_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
s.group_members
.remove(group.id, AdminUserId::from(user_id))
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------
/// Resolve a group identifier (slug or UUID) to a `Group`.
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
groups.get_by_id(uuid.into()).await?
} else {
groups.get_by_slug(ident).await?
};
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
}
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
if slug.is_empty() || slug.len() > SLUG_MAX {
return Err(invalid("must be 163 characters"));
}
let mut chars = slug.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(invalid("must start with a lowercase letter or digit"));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(invalid(
"may contain only lowercase letters, digits, and hyphens",
));
}
if RESERVED_SLUGS.contains(&slug) {
return Err(invalid("is a reserved word"));
}
Ok(())
}
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
if !user.is_active {
return Err(GroupsApiError::TargetInactive {
username: user.username.clone(),
});
}
if user.instance_role != InstanceRole::Member {
return Err(GroupsApiError::TargetNotMember {
username: user.username.clone(),
instance_role: user.instance_role,
});
}
Ok(())
}
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
GroupMemberDto {
user_id: user.id,
username: user.username,
email: user.email,
instance_role: user.instance_role,
is_active: user.is_active,
role: membership.role,
created_at: membership.created_at,
}
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum GroupsApiError {
#[error("group not found: {0}")]
GroupNotFound(String),
#[error("user not found: {0}")]
UserNotFound(AdminUserId),
#[error("{username} is already a member of this group")]
AlreadyMember { username: String },
#[error("membership not found")]
MembershipNotFound,
#[error("{username} is deactivated")]
TargetInactive { username: String },
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
TargetNotMember {
username: String,
instance_role: InstanceRole,
},
#[error("invalid slug: {0}")]
InvalidSlug(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("group repository error: {0}")]
Repo(#[from] GroupRepositoryError),
#[error("member repository error: {0}")]
MembersRepo(#[from] GroupMembersRepositoryError),
#[error("user repository error: {0}")]
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
#[error("app repository error: {0}")]
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
}
impl From<AuthzDenied> for GroupsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for GroupsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for GroupsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::GroupNotFound(_)
| Self::UserNotFound(_)
| Self::MembershipNotFound
| Self::Repo(GroupRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::AlreadyMember { .. } | Self::Conflict(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Repo(GroupRepositoryError::Conflict(msg)) => {
(StatusCode::CONFLICT, json!({ "error": msg }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "groups authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Repo(GroupRepositoryError::Db(e)) => {
tracing::error!(error = %e, "groups api db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::MembersRepo(e) => {
tracing::error!(error = %e, "group members repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::UsersRepo(e) => {
tracing::error!(error = %e, "groups api user repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::AppsRepo(e) => {
tracing::error!(error = %e, "groups api app repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -47,6 +47,9 @@ pub mod files_repo;
pub mod files_service; pub mod files_service;
pub mod files_sweep; pub mod files_sweep;
pub mod gc; pub mod gc;
pub mod group_members_repo;
pub mod group_repo;
pub mod groups_api;
pub mod http_service; pub mod http_service;
pub mod invoke_service; pub mod invoke_service;
pub mod kv_api; pub mod kv_api;
@@ -165,6 +168,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl; pub use files_service::FilesServiceImpl;
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats}; pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc}; pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
pub use group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
PostgresGroupMembersRepository,
};
pub use group_repo::{
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
ROOT_GROUP_SLUG,
};
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
pub use http_service::{HttpConfig, HttpServiceImpl}; pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_api::{kv_admin_router, KvAdminState}; pub use kv_api::{kv_admin_router, KvAdminState};
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};

View File

@@ -383,6 +383,7 @@ mod tests {
slug: self.slug.clone(), slug: self.slug.clone(),
name: "test".into(), name: "test".into(),
description: None, description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
@@ -396,6 +397,12 @@ mod tests {
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> { async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }
async fn list_for_group(
&self,
_: picloud_shared::GroupId,
) -> Result<Vec<App>, ScriptRepositoryError> {
unimplemented!()
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> { async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
if id != self.id { if id != self.id {
return Ok(None); return Ok(None);
@@ -436,6 +443,7 @@ mod tests {
_: &str, _: &str,
_: &str, _: &str,
_: Option<&str>, _: Option<&str>,
_: picloud_shared::GroupId,
) -> Result<App, ScriptRepositoryError> { ) -> Result<App, ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }
@@ -444,6 +452,7 @@ mod tests {
_: &str, _: &str,
_: &str, _: &str,
_: Option<&str>, _: Option<&str>,
_: picloud_shared::GroupId,
) -> Result<App, ScriptRepositoryError> { ) -> Result<App, ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }

View File

@@ -1209,6 +1209,7 @@ mod tests {
slug: "test".into(), slug: "test".into(),
name: "test".into(), name: "test".into(),
description: None, description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}, },
@@ -1226,6 +1227,7 @@ mod tests {
_slug: &str, _slug: &str,
_name: &str, _name: &str,
_description: Option<&str>, _description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> { ) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }
@@ -1234,6 +1236,7 @@ mod tests {
_slug: &str, _slug: &str,
_name: &str, _name: &str,
_description: Option<&str>, _description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> { ) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }
@@ -1252,6 +1255,12 @@ mod tests {
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> { ) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
unimplemented!() unimplemented!()
} }
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn get_by_id( async fn get_by_id(
&self, &self,
id: AppId, id: AppId,
@@ -1725,6 +1734,7 @@ mod tests {
slug: "a".into(), slug: "a".into(),
name: "a".into(), name: "a".into(),
description: None, description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}, },
@@ -1736,6 +1746,7 @@ mod tests {
slug: "b".into(), slug: "b".into(),
name: "b".into(), name: "b".into(),
description: None, description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}, },

View File

@@ -12,26 +12,28 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations, dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router, migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
TriggersState, UsersServiceConfig, UsersServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -109,6 +111,10 @@ pub async fn build_app(
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone())); let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone())); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone())); let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let groups_repo: Arc<dyn GroupRepository> =
Arc::new(PostgresGroupRepository::new(pool.clone()));
let group_members_repo: Arc<dyn GroupMembersRepository> =
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> = let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool.clone())); Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// The Postgres app_members repo implements both `AppMembersRepository` // The Postgres app_members repo implements both `AppMembersRepository`
@@ -513,6 +519,7 @@ pub async fn build_app(
routes: route_repo, routes: route_repo,
domain_table: app_domain_table.clone(), domain_table: app_domain_table.clone(),
authz: authz.clone(), authz: authz.clone(),
groups: groups_repo.clone(),
}; };
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM // Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
@@ -552,6 +559,13 @@ pub async fn build_app(
members, members,
authz: authz.clone(), authz: authz.clone(),
}; };
let groups_state = GroupsState {
groups: groups_repo.clone(),
group_members: group_members_repo.clone(),
apps: apps_state.apps.clone(),
users: auth.users.clone(),
authz: authz.clone(),
};
let app_users_admin_state = picloud_manager_core::AppUsersState { let app_users_admin_state = picloud_manager_core::AppUsersState {
apps: apps_state.apps.clone(), apps: apps_state.apps.clone(),
authz: authz.clone(), authz: authz.clone(),
@@ -574,6 +588,7 @@ pub async fn build_app(
.merge(admins_router(admins_state)) .merge(admins_router(admins_state))
.merge(apps_router(apps_state)) .merge(apps_router(apps_state))
.merge(app_members_router(app_members_state)) .merge(app_members_router(app_members_state))
.merge(groups_router(groups_state))
.merge(picloud_manager_core::app_users_router( .merge(picloud_manager_core::app_users_router(
app_users_admin_state, app_users_admin_state,
)) ))

View File

@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::AppId; use crate::{AppId, GroupId};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App { pub struct App {
@@ -20,6 +20,9 @@ pub struct App {
pub slug: String, pub slug: String,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
/// Parent group in the org tree (Phase 2). Every app has a parent
/// from day one (§9 backfill seeds the instance root group).
pub group_id: GroupId,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }

View File

@@ -98,6 +98,30 @@ impl AppRole {
_ => None, _ => None,
} }
} }
/// Authority rank: higher = more authority. Used to fold the highest
/// effective role across an app's own membership and every ancestor
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
/// than via a derived `Ord` because the variant declaration order
/// (`AppAdmin` first) is the reverse of authority order.
#[must_use]
pub const fn precedence(self) -> u8 {
match self {
Self::AppAdmin => 3,
Self::Editor => 2,
Self::Viewer => 1,
}
}
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
#[must_use]
pub fn max(self, other: Self) -> Self {
if self.precedence() >= other.precedence() {
self
} else {
other
}
}
} }
/// API-key scope. Exactly seven values; new scopes need a blueprint /// API-key scope. Exactly seven values; new scopes need a blueprint

View File

@@ -0,0 +1,31 @@
//! Groups: a GitLab-like, single-parent org tree ABOVE apps (Phase 2).
//!
//! Groups are a pure org / RBAC / UI container — they own no resources
//! (scripts/secrets stay app-owned). A `group_admin` on any ancestor is
//! implicitly app_admin on every app/subgroup beneath it; resolution
//! takes the highest effective role across the app's own membership and
//! all ancestor group memberships.
//!
//! See docs/design/groups-and-project-tool.md §5.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::GroupId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
/// `None` for a root node. Single-parent keeps the tree acyclic.
pub parent_id: Option<GroupId>,
/// Instance-global identifier, frozen at creation (a rename/reparent
/// never rewrites it — the deployment key stays stable).
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Per-subtree structure version, bumped on every structural mutation
/// (reparent/rename/delete). Not an authz input.
pub structure_version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -57,3 +57,4 @@ id_type!(TriggerId);
id_type!(AppUserId); id_type!(AppUserId);
id_type!(InvitationId); id_type!(InvitationId);
id_type!(QueueMessageId); id_type!(QueueMessageId);
id_type!(GroupId);

View File

@@ -15,6 +15,7 @@ pub mod events;
pub mod exec_summary; pub mod exec_summary;
pub mod execution_log; pub mod execution_log;
pub mod files; pub mod files;
pub mod group;
pub mod http; pub mod http;
pub mod ids; pub mod ids;
pub mod inbox; pub mod inbox;
@@ -62,10 +63,11 @@ pub use files::{
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService, FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
SAFE_RENDER_FALLBACK, SAFE_RENDER_FALLBACK,
}; };
pub use group::Group;
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{ pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId, AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
ScriptId, TriggerId, RequestId, ScriptId, TriggerId,
}; };
pub use inbox::{ pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,