From 2b27012f56be04540c9f2418cd5b844f2064a0b7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 19:58:48 +0200 Subject: [PATCH 1/5] feat(groups): schema + root-group backfill (migration 0047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (blueprint §5): groups form a single-parent org tree ABOVE apps. This migration adds the tree + membership tables and gives every app a parent (§9 adoption): - groups: id, parent_id (self-FK ON DELETE RESTRICT), slug (instance- global UNIQUE, frozen at creation), name, description, structure_version (bumped on structural mutation), owner_project (inert §7 seam). - group_members: (group_id, user_id, role) with the SAME three role literals as app_members so AppRole round-trips and the authz rank table covers both. - apps.group_id: nullable add → backfill every app under a seeded 'root' group → promote to NOT NULL + FK RESTRICT. No group-owned resources yet (scripts/secrets stay app-owned — Phase 3). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../manager-core/migrations/0047_groups.sql | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 crates/manager-core/migrations/0047_groups.sql diff --git a/crates/manager-core/migrations/0047_groups.sql b/crates/manager-core/migrations/0047_groups.sql new file mode 100644 index 0000000..979fa69 --- /dev/null +++ b/crates/manager-core/migrations/0047_groups.sql @@ -0,0 +1,82 @@ +-- Phase 2: groups as a pure org / RBAC / UI container — see +-- docs/design/groups-and-project-tool.md §5, §9. +-- +-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds +-- the tree, hierarchy-aware membership, and structural-mutation safety — +-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned; +-- that is Phase 3). The only data-plane touch is apps gaining a parent +-- pointer. +-- +-- Adoption (§9): every existing app must have a parent from day one so +-- resolution always terminates. This migration seeds a single instance +-- root group and reparents every app under it, then promotes +-- apps.group_id to NOT NULL. + +CREATE TABLE groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Single parent keeps inheritance acyclic and resolution + -- deterministic. NULL parent = a root node. RESTRICT (never CASCADE): + -- deleting a non-empty group is refused so descendant apps and their + -- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk + -- cycle guard that keeps this acyclic lives in manager-core (a SQL + -- CHECK can't express it). + parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT, + -- Instance-global identifier, frozen at creation. A rename/reparent + -- updates the display name/path but NEVER rewrites the slug, so the + -- deployment key stays stable and external references don't break. + -- Format validation lives in Rust handlers (same rule as app slugs). + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + -- Per-subtree structure version (§6): bumped on every structural + -- mutation of this node (reparent/rename/delete) so a future CLI/ + -- orchestrator can detect structural drift. NOT an authz input — + -- authorization is resolved live every request. + structure_version BIGINT NOT NULL DEFAULT 1, + -- §7 ownership seam — the project-root that manages this node. Inert + -- in Phase 2 (no projects table yet); nullable, no FK. + owner_project UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX groups_parent_id_idx ON groups (parent_id); + +-- Per-(user, group) explicit grant, mirroring app_members. Inherited +-- membership (GitLab-style) is resolved in code by walking ancestors: a +-- group_admin on any ancestor is implicitly app_admin on every app and +-- subgroup beneath it. Roles reuse the SAME three literals as app_members +-- so AppRole round-trips with zero mapping and the authz rank table +-- covers both tables. +CREATE TABLE group_members ( + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (group_id, user_id) +); + +-- Hot path is the authz ancestor walk "what groups does this user have a +-- role on?" plus the per-group member list. +CREATE INDEX group_members_user_id_idx ON group_members (user_id); + +-- Add the parent pointer to apps (nullable for the backfill window). +ALTER TABLE apps ADD COLUMN group_id UUID; + +-- Seed a single instance root group and reparent every existing app under +-- it. App slugs are already instance-global, so no slug rewrite is needed +-- — the parent pointer is new metadata layered on top. +WITH root_group AS ( + INSERT INTO groups (slug, name, description) + VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.') + RETURNING id +) +UPDATE apps SET group_id = (SELECT id FROM root_group); + +-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a +-- group with apps can't be deleted out from under them (§5.6). +ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL; +ALTER TABLE apps + ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT; + +CREATE INDEX apps_group_id_idx ON apps (group_id); From a432091191f81717de002f032480a9e2866e4653 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 19:59:00 +0200 Subject: [PATCH 2/5] feat(groups): tree repos, hierarchy-aware RBAC, admin API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/manager-core/src/app_members_repo.rs | 92 ++- crates/manager-core/src/app_repo.rs | 51 +- crates/manager-core/src/apps_api.rs | 84 ++- crates/manager-core/src/authz.rs | 359 ++++++++++- crates/manager-core/src/group_members_repo.rs | 205 +++++++ crates/manager-core/src/group_repo.rs | 367 +++++++++++ crates/manager-core/src/groups_api.rs | 577 ++++++++++++++++++ crates/manager-core/src/lib.rs | 12 + crates/manager-core/src/topics_api.rs | 9 + crates/manager-core/src/triggers_api.rs | 11 + crates/picloud/src/lib.rs | 45 +- crates/shared/src/app.rs | 5 +- crates/shared/src/auth.rs | 24 + crates/shared/src/group.rs | 31 + crates/shared/src/ids.rs | 1 + crates/shared/src/lib.rs | 6 +- 16 files changed, 1826 insertions(+), 53 deletions(-) create mode 100644 crates/manager-core/src/group_members_repo.rs create mode 100644 crates/manager-core/src/group_repo.rs create mode 100644 crates/manager-core/src/groups_api.rs create mode 100644 crates/shared/src/group.rs diff --git a/crates/manager-core/src/app_members_repo.rs b/crates/manager-core/src/app_members_repo.rs index 6a7fb06..01d961b 100644 --- a/crates/manager-core/src/app_members_repo.rs +++ b/crates/manager-core/src/app_members_repo.rs @@ -8,11 +8,17 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole}; +use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId}; use sqlx::PgPool; 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)] pub enum AppMembersRepositoryError { #[error("database error: {0}")] @@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository { impl AuthzRepo for PostgresAppMembersRepository { async fn membership( &self, - user_id: AdminUserId, + user_id: UserId, app_id: AppId, ) -> Result, AuthzError> { self.find(user_id, app_id) .await .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, 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, 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)] diff --git a/crates/manager-core/src/app_repo.rs b/crates/manager-core/src/app_repo.rs index 21dcbf0..8936a3c 100644 --- a/crates/manager-core/src/app_repo.rs +++ b/crates/manager-core/src/app_repo.rs @@ -6,7 +6,7 @@ //! that writes the history row in the same transaction. use async_trait::async_trait; -use picloud_shared::{AdminUserId, App, AppId}; +use picloud_shared::{AdminUserId, App, AppId, GroupId}; use sqlx::PgPool; use uuid::Uuid; @@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync { /// Only apps the user has an `app_members` row for. Drives the /// membership-filtered `GET /admin/apps` for `member` callers. async fn list_for_user(&self, user_id: AdminUserId) -> Result, ScriptRepositoryError>; + /// Apps whose parent is `group_id`. Drives the group detail view. + async fn list_for_group(&self, group_id: GroupId) -> Result, ScriptRepositoryError>; async fn get_by_id(&self, id: AppId) -> Result, ScriptRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result, ScriptRepositoryError>; async fn get_by_slug_or_history( @@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync { slug: &str, name: &str, description: Option<&str>, + group_id: GroupId, ) -> Result; /// Create that also consumes a matching `app_slug_history` row, if /// any. Used after the operator has confirmed they want to break old @@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync { slug: &str, name: &str, description: Option<&str>, + group_id: GroupId, ) -> Result; async fn update( &self, @@ -116,7 +120,7 @@ impl PostgresAppRepository { impl AppRepository for PostgresAppRepository { async fn list(&self) -> Result, ScriptRepositoryError> { 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", ) .fetch_all(&self.pool) @@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository { async fn list_for_user(&self, user_id: AdminUserId) -> Result, ScriptRepositoryError> { 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 \ JOIN app_members m ON m.app_id = a.id \ WHERE m.user_id = $1 \ @@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository { Ok(rows.into_iter().map(Into::into).collect()) } + async fn list_for_group(&self, group_id: GroupId) -> Result, 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, ScriptRepositoryError> { 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", ) .bind(id.into_inner()) @@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository { async fn get_by_slug(&self, slug: &str) -> Result, ScriptRepositoryError> { 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", ) .bind(slug) @@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository { async fn slug_in_history(&self, slug: &str) -> Result, ScriptRepositoryError> { 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 \ JOIN apps a ON a.id = h.current_app_id \ WHERE h.slug = $1", @@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository { slug: &str, name: &str, description: Option<&str>, + group_id: GroupId, ) -> Result { let res = sqlx::query_as::<_, AppRow>( - "INSERT INTO apps (slug, name, description) \ - VALUES ($1, $2, $3) \ - RETURNING id, slug, name, description, created_at, updated_at", + "INSERT INTO apps (slug, name, description, group_id) \ + VALUES ($1, $2, $3, $4) \ + RETURNING id, slug, name, description, group_id, created_at, updated_at", ) .bind(slug) .bind(name) .bind(description) + .bind(group_id.into_inner()) .fetch_one(&self.pool) .await; @@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository { slug: &str, name: &str, description: Option<&str>, + group_id: GroupId, ) -> Result { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM app_slug_history WHERE slug = $1") @@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository { .execute(&mut *tx) .await?; let row = sqlx::query_as::<_, AppRow>( - "INSERT INTO apps (slug, name, description) \ - VALUES ($1, $2, $3) \ - RETURNING id, slug, name, description, created_at, updated_at", + "INSERT INTO apps (slug, name, description, group_id) \ + VALUES ($1, $2, $3, $4) \ + RETURNING id, slug, name, description, group_id, created_at, updated_at", ) .bind(slug) .bind(name) .bind(description) + .bind(group_id.into_inner()) .fetch_one(&mut *tx) .await; let row = match row { @@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository { description = CASE WHEN $3::bool THEN $4 ELSE description END, \ updated_at = NOW() \ 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(name) @@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository { if current_slug == new_slug { // No-op rename; just return the row. 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", ) .bind(id.into_inner()) @@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository { let row = sqlx::query_as::<_, AppRow>( "UPDATE apps SET slug = $2, updated_at = NOW() \ 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(new_slug) @@ -432,6 +451,7 @@ struct AppRow { slug: String, name: String, description: Option, + group_id: uuid::Uuid, created_at: chrono::DateTime, updated_at: chrono::DateTime, } @@ -443,6 +463,7 @@ impl From for App { slug: r.slug, name: r.name, description: r.description, + group_id: r.group_id.into(), created_at: r.created_at, updated_at: r.updated_at, } diff --git a/crates/manager-core/src/apps_api.rs b/crates/manager-core/src/apps_api.rs index 7c2d103..4326077 100644 --- a/crates/manager-core/src/apps_api.rs +++ b/crates/manager-core/src/apps_api.rs @@ -25,6 +25,7 @@ use uuid::Uuid; use crate::app_domain_repo::{AppDomainRepository, NewAppDomain}; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; +use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG}; use crate::repo::ScriptRepositoryError; use crate::route_repo::RouteRepository; @@ -44,6 +45,9 @@ pub struct AppsState { pub domain_table: Arc, /// Capability gate — Phase 3.5. pub authz: Arc, + /// Group tree — resolves an app's parent group at create time + /// (defaults to the instance root). + pub groups: Arc, } pub fn apps_router(state: AppsState) -> Router { @@ -80,6 +84,10 @@ pub struct CreateAppRequest { pub slug: String, pub name: String, pub description: Option, + /// 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, /// Set to `true` to consume an existing `app_slug_history` row for /// the requested slug (breaking old redirects). #[serde(default)] @@ -176,23 +184,46 @@ async fn create_app( require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?; 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 // and the caller hasn't asked to force takeover, surface a clean // 409 so the dashboard can present a "this will break old links" // confirmation. if !input.force_takeover { 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 { 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? } else { s.apps - .create(&input.slug, &input.name, input.description.as_deref()) + .create( + &input.slug, + &input.name, + input.description.as_deref(), + parent.id, + ) .await? }; Ok((StatusCode::CREATED, Json(created))) @@ -235,7 +266,31 @@ async fn compute_my_role( ) -> Result, AppsApiError> { match principal.instance_role { 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 { + 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::() { + 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, Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => { 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)); } @@ -521,14 +576,19 @@ pub enum AppsApiError { #[error("app not found: {0}")] AppNotFound(String), + #[error("group not found: {0}")] + GroupNotFound(String), + #[error("domain not found: {0}")] DomainNotFound(Uuid), #[error("invalid slug: {0}")] 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")] - SlugInHistory(App), + SlugInHistory(Box), #[error("app still contains {0} script(s); delete or move them first")] HasScripts(i64), @@ -567,10 +627,22 @@ impl From for AppsApiError { } } +impl From 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 { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) + | Self::GroupNotFound(_) | Self::DomainNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 5fc5c21..9f1cf56 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -27,7 +27,7 @@ //! external user-facing label. 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 /// 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 { /// Create a new app. Owner / admin only. InstanceCreateApp, + /// Create a new group (root-level). Owner / admin only — a Member + /// creates subgroups under a group they group-admin (gated by + /// `GroupAdmin(parent)` at the handler), not via this instance cap. + InstanceCreateGroup, + /// Read group metadata + list its subgroups/apps. Viewer+ on the + /// group (inherited from any ancestor); implicit for admin / owner. + GroupRead(GroupId), + /// Rename / edit group metadata, move apps into it. Editor+ on the + /// group. + GroupWrite(GroupId), + /// Group settings: delete, reparent, manage group members. group_admin + /// on the group (inherited from any ancestor). + GroupAdmin(GroupId), /// Create / update / delete admin_users rows (other than self /// password change, which is a separate flow). Owner / admin. InstanceManageUsers, @@ -154,9 +167,16 @@ impl Capability { #[must_use] pub const fn app_id(self) -> Option { match self { - Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { - None - } + Self::InstanceCreateApp + | Self::InstanceManageUsers + | Self::InstanceManageSettings + | Self::InstanceCreateGroup + // Group-scoped caps carry a GroupId, not an AppId. They return + // None here so a bound API key (which can only target its one + // app) is denied group management at the binding layer. + | Self::GroupRead(_) + | Self::GroupWrite(_) + | Self::GroupAdmin(_) => None, Self::AppRead(id) | Self::AppWriteScript(id) | Self::AppWriteRoute(id) @@ -193,15 +213,17 @@ impl Capability { #[must_use] pub const fn required_scope(self) -> Scope { match self { - Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { - Scope::InstanceAdmin - } + Self::InstanceCreateApp + | Self::InstanceManageUsers + | Self::InstanceManageSettings + | Self::InstanceCreateGroup => Scope::InstanceAdmin, Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) | Self::AppFilesRead(_) | Self::AppSecretsRead(_) - | Self::AppUsersRead(_) => Scope::ScriptRead, + | Self::AppUsersRead(_) + | Self::GroupRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) @@ -219,7 +241,9 @@ impl Capability { Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) - | Self::AppTopicManage(_) => Scope::AppAdmin, + | Self::AppTopicManage(_) + | Self::GroupWrite(_) + | Self::GroupAdmin(_) => Scope::AppAdmin, Self::AppLogRead(_) => Scope::LogRead, } } @@ -230,11 +254,41 @@ impl Capability { /// means unit tests can stub it. #[async_trait] pub trait AuthzRepo: Send + Sync { + /// Direct `app_members` row for (user, app). The single-row lookup + /// used by member-management surfaces and as the fallback below. async fn membership( &self, user_id: UserId, app_id: AppId, ) -> Result, 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, 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, AuthzError> { + Ok(None) + } } /// Repo errors surface here so handlers can map them to 500 without @@ -353,7 +407,20 @@ async fn role_grants( match principal.instance_role { InstanceRole::Owner => Ok(true), 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 { 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); }; 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 { + let Some(role) = repo.effective_group_role(user_id, group_id).await? else { + return Ok(false); + }; + Ok(group_role_satisfies(role, cap)) +} + +/// Does the effective group `AppRole` cover the group capability? +/// viewer→read, editor→write, group_admin(=AppAdmin)→admin. +const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool { + match cap { + Capability::GroupRead(_) => true, // any role can read + Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin), + Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin), + _ => false, + } +} + /// Does the per-app `AppRole` cover the capability? Viewer can read; /// Editor adds script/route/log mutations; AppAdmin adds settings, /// domain claims, and delete. Roles form a strict subset chain, so @@ -471,16 +567,61 @@ mod tests { use std::collections::HashMap; 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)] struct InMemoryAuthzRepo { memberships: Mutex>, + app_group: Mutex>, + group_parent: Mutex>>, + group_memberships: Mutex>, } impl InMemoryAuthzRepo { async fn grant(&self, user: UserId, app: AppId, role: AppRole) { self.memberships.lock().await.insert((user, app), role); } + /// Register a group node and its parent (`None` = root). + async fn add_group(&self, group: GroupId, parent: Option) { + 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, + mut acc: Option, + ) -> Option { + 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] @@ -497,6 +638,29 @@ mod tests { .get(&(user_id, app_id)) .copied()) } + + async fn effective_app_role( + &self, + user_id: UserId, + app_id: AppId, + ) -> Result, 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, AuthzError> { + Ok(self.fold_group_chain(user_id, Some(group_id), None).await) + } } 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] fn capability_app_id_extraction() { let app = AppId::new(); assert_eq!(Capability::InstanceCreateApp.app_id(), None); assert_eq!(Capability::AppRead(app).app_id(), Some(app)); assert_eq!(Capability::AppAdmin(app).app_id(), Some(app)); + // Group caps are not app-scoped. + assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None); + assert_eq!(Capability::InstanceCreateGroup.app_id(), None); } #[test] diff --git a/crates/manager-core/src/group_members_repo.rs b/crates/manager-core/src/group_members_repo.rs new file mode 100644 index 0000000..2e12737 --- /dev/null +++ b/crates/manager-core/src/group_members_repo.rs @@ -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, +} + +/// `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, + pub instance_role: InstanceRole, + pub is_active: bool, + pub role: AppRole, + pub created_at: DateTime, +} + +#[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, 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, 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, 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, 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, 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, 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, +} + +impl TryFrom for GroupMembershipRow { + type Error = GroupMembersRepositoryError; + fn try_from(r: GroupMembershipRecord) -> Result { + 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, + instance_role: String, + is_active: bool, + role: String, + created_at: DateTime, +} + +impl TryFrom for GroupMembershipDetail { + type Error = GroupMembersRepositoryError; + fn try_from(r: GroupMembershipDetailRecord) -> Result { + 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, + }) + } +} diff --git a/crates/manager-core/src/group_repo.rs b/crates/manager-core/src/group_repo.rs new file mode 100644 index 0000000..aaa1b4f --- /dev/null +++ b/crates/manager-core/src/group_repo.rs @@ -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, GroupRepositoryError>; + async fn get_by_id(&self, id: GroupId) -> Result, GroupRepositoryError>; + async fn get_by_slug(&self, slug: &str) -> Result, GroupRepositoryError>; + /// Direct children groups of `parent`. + async fn list_children(&self, parent: GroupId) -> Result, 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, GroupRepositoryError>; + async fn child_counts(&self, id: GroupId) -> Result; + async fn create( + &self, + slug: &str, + name: &str, + description: Option<&str>, + parent_id: Option, + ) -> Result; + /// Edit display fields only — the slug is frozen at creation. Bumps + /// `structure_version`. + async fn rename( + &self, + id: GroupId, + name: Option<&str>, + description: Option>, + ) -> Result; + /// 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, + ) -> Result; + /// 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, 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, 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, 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, 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, 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 { + 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, + ) -> Result { + 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>, + ) -> Result { + // 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, + ) -> Result { + 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,)> = + 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, + slug: String, + name: String, + description: Option, + structure_version: i64, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +impl From 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, + } + } +} diff --git a/crates/manager-core/src/groups_api.rs b/crates/manager-core/src/groups_api.rs new file mode 100644 index 0000000..2599ff7 --- /dev/null +++ b/crates/manager-core/src/groups_api.rs @@ -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, + pub group_members: Arc, + pub apps: Arc, + pub users: Arc, + pub authz: Arc, +} + +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, + pub subgroups: Vec, + pub apps: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateGroupRequest { + pub slug: String, + pub name: String, + pub description: Option, + /// Parent group (slug or id). Omitted ⇒ a root-level group + /// (owner/admin only). + #[serde(default)] + pub parent: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PatchGroupRequest { + pub name: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ReparentRequest { + /// New parent (slug or id). Omitted/null ⇒ move to root. + #[serde(default)] + pub parent: Option, +} + +#[derive(Debug, Serialize)] +pub struct GroupMemberDto { + pub user_id: AdminUserId, + pub username: String, + pub email: Option, + pub instance_role: InstanceRole, + pub is_active: bool, + pub role: AppRole, + pub created_at: DateTime, +} + +impl From 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, + Extension(_principal): Extension, +) -> Result>, GroupsApiError> { + Ok(Json(s.groups.list().await?)) +} + +async fn create_group( + State(s): State, + Extension(principal): Extension, + Json(input): Json, +) -> Result<(StatusCode, Json), 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, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result { + 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, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), 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, + Extension(principal): Extension, + Path((id_or_slug, user_id)): Path<(String, Uuid)>, + Json(input): Json, +) -> Result, 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, + Extension(principal): Extension, + Path((id_or_slug, user_id)): Path<(String, Uuid)>, +) -> Result { + 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 { + let found = if let Ok(uuid) = ident.parse::() { + 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 1–63 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 for GroupsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From 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() + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index c13b2fa..ea7f5bc 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -47,6 +47,9 @@ pub mod files_repo; pub mod files_service; pub mod files_sweep; pub mod gc; +pub mod group_members_repo; +pub mod group_repo; +pub mod groups_api; pub mod http_service; pub mod invoke_service; pub mod kv_api; @@ -165,6 +168,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo}; pub use files_service::FilesServiceImpl; 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 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 kv_api::{kv_admin_router, KvAdminState}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; diff --git a/crates/manager-core/src/topics_api.rs b/crates/manager-core/src/topics_api.rs index 580c049..43fc79d 100644 --- a/crates/manager-core/src/topics_api.rs +++ b/crates/manager-core/src/topics_api.rs @@ -383,6 +383,7 @@ mod tests { slug: self.slug.clone(), name: "test".into(), description: None, + group_id: picloud_shared::GroupId::new(), created_at: now, updated_at: now, } @@ -396,6 +397,12 @@ mod tests { async fn list_for_user(&self, _: AdminUserId) -> Result, ScriptRepositoryError> { unimplemented!() } + async fn list_for_group( + &self, + _: picloud_shared::GroupId, + ) -> Result, ScriptRepositoryError> { + unimplemented!() + } async fn get_by_id(&self, id: AppId) -> Result, ScriptRepositoryError> { if id != self.id { return Ok(None); @@ -436,6 +443,7 @@ mod tests { _: &str, _: &str, _: Option<&str>, + _: picloud_shared::GroupId, ) -> Result { unimplemented!() } @@ -444,6 +452,7 @@ mod tests { _: &str, _: &str, _: Option<&str>, + _: picloud_shared::GroupId, ) -> Result { unimplemented!() } diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index 15ef771..9b9da89 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -1209,6 +1209,7 @@ mod tests { slug: "test".into(), name: "test".into(), description: None, + group_id: picloud_shared::GroupId::new(), created_at: now, updated_at: now, }, @@ -1226,6 +1227,7 @@ mod tests { _slug: &str, _name: &str, _description: Option<&str>, + _group_id: picloud_shared::GroupId, ) -> Result { unimplemented!() } @@ -1234,6 +1236,7 @@ mod tests { _slug: &str, _name: &str, _description: Option<&str>, + _group_id: picloud_shared::GroupId, ) -> Result { unimplemented!() } @@ -1252,6 +1255,12 @@ mod tests { ) -> Result, crate::repo::ScriptRepositoryError> { unimplemented!() } + async fn list_for_group( + &self, + _group_id: picloud_shared::GroupId, + ) -> Result, crate::repo::ScriptRepositoryError> { + unimplemented!() + } async fn get_by_id( &self, id: AppId, @@ -1725,6 +1734,7 @@ mod tests { slug: "a".into(), name: "a".into(), description: None, + group_id: picloud_shared::GroupId::new(), created_at: now, updated_at: now, }, @@ -1736,6 +1746,7 @@ mod tests { slug: "b".into(), name: "b".into(), description: None, + group_id: picloud_shared::GroupId::new(), created_at: now, updated_at: now, }, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 0d05c39..40d4850 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -12,26 +12,28 @@ use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ 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, - dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations, - require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router, - AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, - AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, - AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, - DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, - EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, - HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, - OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, + migrations, require_authenticated, route_admin_router, secrets_router, topics_router, + triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, + AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, + AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, + AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, + EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, + FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, + InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, + PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, - PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, - PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, - RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, - SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, - TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, + PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository, + PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, + PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, + PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, + RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, + SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, + TriggersState, UsersServiceConfig, UsersServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -109,6 +111,10 @@ pub async fn build_app( let log_sink: Arc = Arc::new(PostgresExecutionLogSink::new(pool.clone())); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone())); let apps_repo: Arc = Arc::new(PostgresAppRepository::new(pool.clone())); + let groups_repo: Arc = + Arc::new(PostgresGroupRepository::new(pool.clone())); + let group_members_repo: Arc = + Arc::new(PostgresGroupMembersRepository::new(pool.clone())); let domains_repo: Arc = Arc::new(PostgresAppDomainRepository::new(pool.clone())); // The Postgres app_members repo implements both `AppMembersRepository` @@ -513,6 +519,7 @@ pub async fn build_app( routes: route_repo, domain_table: app_domain_table.clone(), authz: authz.clone(), + groups: groups_repo.clone(), }; // Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM @@ -552,6 +559,13 @@ pub async fn build_app( members, 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 { apps: apps_state.apps.clone(), authz: authz.clone(), @@ -574,6 +588,7 @@ pub async fn build_app( .merge(admins_router(admins_state)) .merge(apps_router(apps_state)) .merge(app_members_router(app_members_state)) + .merge(groups_router(groups_state)) .merge(picloud_manager_core::app_users_router( app_users_admin_state, )) diff --git a/crates/shared/src/app.rs b/crates/shared/src/app.rs index 406fe6f..a720edf 100644 --- a/crates/shared/src/app.rs +++ b/crates/shared/src/app.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::AppId; +use crate::{AppId, GroupId}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct App { @@ -20,6 +20,9 @@ pub struct App { pub slug: String, pub name: String, pub description: Option, + /// 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, pub updated_at: DateTime, } diff --git a/crates/shared/src/auth.rs b/crates/shared/src/auth.rs index 1b6cf40..c70a109 100644 --- a/crates/shared/src/auth.rs +++ b/crates/shared/src/auth.rs @@ -98,6 +98,30 @@ impl AppRole { _ => 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 diff --git a/crates/shared/src/group.rs b/crates/shared/src/group.rs new file mode 100644 index 0000000..b5a1d4e --- /dev/null +++ b/crates/shared/src/group.rs @@ -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, + /// 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, + /// Per-subtree structure version, bumped on every structural mutation + /// (reparent/rename/delete). Not an authz input. + pub structure_version: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/shared/src/ids.rs b/crates/shared/src/ids.rs index 6204c72..e61e494 100644 --- a/crates/shared/src/ids.rs +++ b/crates/shared/src/ids.rs @@ -57,3 +57,4 @@ id_type!(TriggerId); id_type!(AppUserId); id_type!(InvitationId); id_type!(QueueMessageId); +id_type!(GroupId); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index d70ddf6..94a6992 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -15,6 +15,7 @@ pub mod events; pub mod exec_summary; pub mod execution_log; pub mod files; +pub mod group; pub mod http; pub mod ids; pub mod inbox; @@ -62,10 +63,11 @@ pub use files::{ FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService, SAFE_RENDER_FALLBACK, }; +pub use group::Group; pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; pub use ids::{ - AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId, - ScriptId, TriggerId, + AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId, + RequestId, ScriptId, TriggerId, }; pub use inbox::{ InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, From eea1d8984e4eefd9f0a90274c7958fc6d51c7236 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 20:15:33 +0200 Subject: [PATCH 3/5] feat(cli): pic groups (tree/create/rename/reparent/rm/members) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `pic groups` command family over /api/v1/admin/groups: ls/tree, create (--parent), show (path + subgroups + apps), rename (name/description; slug frozen), reparent (--to), rm (--recursive, leaf-first; never auto-deletes apps), and members add/set/rm/ls. Also `pic apps create --group ` to place an app under a group. End-to-end journey tests (tests/groups.rs, DB-gated) cover tree CRUD, delete=RESTRICT (409 on a non-empty group), reparent cycle rejection, and the headline invariant: a group_admin on an ancestor group can deploy to an app it is NOT a direct member of (inherited membership), and loses that access immediately on revoke — all green against a live server. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/src/client.rs | 166 ++++++++++++- crates/picloud-cli/src/cmds/apps.rs | 2 + crates/picloud-cli/src/cmds/groups.rs | 257 +++++++++++++++++++++ crates/picloud-cli/src/cmds/mod.rs | 1 + crates/picloud-cli/src/main.rs | 163 ++++++++++++- crates/picloud-cli/tests/cli.rs | 1 + crates/picloud-cli/tests/common/cleanup.rs | 29 +++ crates/picloud-cli/tests/groups.rs | 179 ++++++++++++++ 8 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 crates/picloud-cli/src/cmds/groups.rs create mode 100644 crates/picloud-cli/tests/groups.rs diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index eeb8a72..e53f9a4 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -12,7 +12,8 @@ use chrono::{DateTime, Utc}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use picloud_shared::{ AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, - HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox, + Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, + ScriptSandbox, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; @@ -149,6 +150,144 @@ impl Client { decode_status(resp).await } + // --- Groups (Phase 2) ------------------------------------------------- + + /// `GET /api/v1/admin/groups` — the full flat list (assemble the tree + /// client-side from `parent_id`). + pub async fn groups_list(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/groups") + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. + pub async fn groups_get(&self, ident: &str) -> Result { + let ident = seg(ident); + let resp = self + .request(Method::GET, &format!("/api/v1/admin/groups/{ident}")) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/groups` + pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/groups") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only + /// (the slug is frozen). + pub async fn groups_rename( + &self, + ident: &str, + name: Option<&str>, + description: Option<&str>, + ) -> Result { + let ident = seg(ident); + let body = serde_json::json!({ "name": name, "description": description }); + let resp = self + .request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}")) + .json(&body) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a + /// slug/id, or `None` to move to root. + pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result { + let ident = seg(ident); + let body = serde_json::json!({ "parent": parent }); + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/groups/{ident}/reparent"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty. + pub async fn groups_delete(&self, ident: &str) -> Result<()> { + let ident = seg(ident); + let resp = self + .request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}")) + .send() + .await?; + decode_status(resp).await + } + + pub async fn group_members_list(&self, group: &str) -> Result> { + let group = seg(group); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{group}/members"), + ) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_grant( + &self, + group: &str, + user_id: &str, + role: AppRole, + ) -> Result { + let group = seg(group); + let body = serde_json::json!({ "user_id": user_id, "role": role }); + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/groups/{group}/members"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_set_role( + &self, + group: &str, + user_id: &str, + role: AppRole, + ) -> Result { + let (group, user_id) = (seg(group), seg(user_id)); + let body = serde_json::json!({ "role": role }); + let resp = self + .request( + Method::PATCH, + &format!("/api/v1/admin/groups/{group}/members/{user_id}"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> { + let (group, user_id) = (seg(group), seg(user_id)); + let resp = self + .request( + Method::DELETE, + &format!("/api/v1/admin/groups/{group}/members/{user_id}"), + ) + .send() + .await?; + decode_status(resp).await + } + /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the /// owning app (stricter than the edit endpoints, by design). pub async fn scripts_delete(&self, id: &str) -> Result<()> { @@ -1049,6 +1188,31 @@ pub struct CreateAppBody<'a> { pub name: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option<&'a str>, + /// Parent group (slug or id); omit for the instance root. + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct CreateGroupBody<'a> { + pub slug: &'a str, + pub name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'a str>, + /// Parent group (slug or id); omit for a root-level group. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option<&'a str>, +} + +/// `GET /groups/{id}` response — the group plus its breadcrumb path and +/// direct children. +#[derive(Debug, Deserialize)] +pub struct GroupDetailDto { + #[serde(flatten)] + pub group: Group, + pub path: Vec, + pub subgroups: Vec, + pub apps: Vec, } #[derive(Debug, Serialize)] diff --git a/crates/picloud-cli/src/cmds/apps.rs b/crates/picloud-cli/src/cmds/apps.rs index d19894a..d4c7a94 100644 --- a/crates/picloud-cli/src/cmds/apps.rs +++ b/crates/picloud-cli/src/cmds/apps.rs @@ -31,6 +31,7 @@ pub async fn create( slug: &str, name: Option<&str>, description: Option<&str>, + group: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; @@ -39,6 +40,7 @@ pub async fn create( slug, name: name.unwrap_or(slug), description, + group, }; let app = client.apps_create(&body).await?; // Emit the created object so `--output json` callers can capture the diff --git a/crates/picloud-cli/src/cmds/groups.rs b/crates/picloud-cli/src/cmds/groups.rs new file mode 100644 index 0000000..a498a3a --- /dev/null +++ b/crates/picloud-cli/src/cmds/groups.rs @@ -0,0 +1,257 @@ +//! `pic groups` — manage the org-tree groups (Phase 2). +//! +//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated +//! server-side: create/reparent/delete need group-admin (reparent at both +//! source and destination parent); the slug is frozen at creation. + +use std::collections::BTreeMap; + +use anyhow::Result; +use picloud_shared::{AppRole, Group}; + +use crate::client::{Client, CreateGroupBody}; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let groups = client.groups_list().await?; + let mut table = Table::new(["slug", "name", "parent", "created_at"]); + let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect(); + for g in &groups { + let parent = g + .parent_id + .and_then(|p| by_id.get(&p).cloned()) + .unwrap_or_else(|| "-".into()); + table.row([ + g.slug.clone(), + g.name.clone(), + parent, + g.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +/// `pic groups tree` — render the hierarchy as an indented tree (text +/// mode); falls back to the flat list for `--output json`. +pub async fn tree(mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let groups = client.groups_list().await?; + if matches!(mode, OutputMode::Json) { + // Machine consumers get the flat list; the shape carries parent_id. + println!("{}", serde_json::to_string_pretty(&groups)?); + return Ok(()); + } + // children-by-parent, then DFS from the roots. + let mut children: BTreeMap, Vec<&Group>> = BTreeMap::new(); + for g in &groups { + children.entry(g.parent_id).or_default().push(g); + } + for kids in children.values_mut() { + kids.sort_by(|a, b| a.name.cmp(&b.name)); + } + print_subtree(&children, None, 0); + Ok(()) +} + +fn print_subtree( + children: &BTreeMap, Vec<&Group>>, + parent: Option, + depth: usize, +) { + let Some(kids) = children.get(&parent) else { + return; + }; + for g in kids { + println!("{}{} ({})", " ".repeat(depth), g.name, g.slug); + print_subtree(children, Some(g.id), depth + 1); + } +} + +pub async fn create( + slug: &str, + name: Option<&str>, + description: Option<&str>, + parent: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let body = CreateGroupBody { + slug, + name: name.unwrap_or(slug), + description, + parent, + }; + let group = client.groups_create(&body).await?; + print_group(&group, mode); + Ok(()) +} + +pub async fn show(ident: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let detail = client.groups_get(ident).await?; + let path = detail + .path + .iter() + .map(|g| g.slug.as_str()) + .collect::>() + .join(" / "); + let mut block = KvBlock::new(); + block + .field("id", detail.group.id.to_string()) + .field("slug", detail.group.slug.clone()) + .field("name", detail.group.name.clone()) + .field("path", if path.is_empty() { "-".into() } else { path }) + .field( + "subgroups", + detail + .subgroups + .iter() + .map(|g| g.slug.as_str()) + .collect::>() + .join(", "), + ) + .field( + "apps", + detail + .apps + .iter() + .map(|a| a.slug.as_str()) + .collect::>() + .join(", "), + ); + block.print(mode); + Ok(()) +} + +pub async fn rename( + ident: &str, + name: Option<&str>, + description: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let group = client.groups_rename(ident, name, description).await?; + print_group(&group, mode); + Ok(()) +} + +pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let group = client.groups_reparent(ident, to).await?; + print_group(&group, mode); + Ok(()) +} + +/// `pic groups rm `. The server enforces delete=RESTRICT (409 on a +/// non-empty group); `--recursive` expands the delete into ordered, +/// leaf-first child deletions (groups + apps) the operator opted into. +pub async fn rm(ident: &str, recursive: bool) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + if !recursive { + client.groups_delete(ident).await?; + println!("Deleted group {ident}"); + return Ok(()); + } + // Recursive: delete the subtree leaf-first so each DB delete sees an + // empty node (the FK stays RESTRICT — we never cascade implicitly). + let detail = client.groups_get(ident).await?; + if let Some(app) = detail.apps.first() { + anyhow::bail!( + "group {ident} contains app {:?}; move or delete apps before a recursive group delete \ + (apps are never auto-deleted)", + app.slug + ); + } + for sub in &detail.subgroups { + Box::pin(rm(&sub.slug, true)).await?; + } + client.groups_delete(ident).await?; + println!("Deleted group {ident}"); + Ok(()) +} + +// --- members --------------------------------------------------------------- + +pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let members = client.group_members_list(group).await?; + let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]); + for m in members { + table.row([ + m.user_id.to_string(), + m.username, + m.role.as_str().to_string(), + format!("{:?}", m.instance_role).to_lowercase(), + m.is_active.to_string(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let m = client + .group_members_grant(group, user_id, parse_role(role)?) + .await?; + print_member(&m, mode); + Ok(()) +} + +pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let m = client + .group_members_set_role(group, user_id, parse_role(role)?) + .await?; + print_member(&m, mode); + Ok(()) +} + +pub async fn members_rm(group: &str, user_id: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.group_members_remove(group, user_id).await?; + println!("Removed {user_id} from {group}"); + Ok(()) +} + +fn print_group(g: &Group, mode: OutputMode) { + let mut block = KvBlock::new(); + block + .field("id", g.id.to_string()) + .field("slug", g.slug.clone()) + .field("name", g.name.clone()) + .field( + "parent_id", + g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()), + ) + .field("created_at", g.created_at.to_rfc3339()); + block.print(mode); +} + +fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) { + let mut block = KvBlock::new(); + block + .field("user_id", m.user_id.to_string()) + .field("username", m.username.clone()) + .field("role", m.role.as_str().to_string()); + block.print(mode); +} + +fn parse_role(role: &str) -> Result { + AppRole::from_db_str(role) + .ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer")) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index a0070ed..8725727 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -6,6 +6,7 @@ pub mod apps_domains; pub mod config; pub mod dead_letters; pub mod files; +pub mod groups; pub mod init; pub mod kv; pub mod login; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 12bc481..6ac8a90 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -51,6 +51,12 @@ enum Cmd { cmd: AppsCmd, }, + /// Group (org-tree) management. + Groups { + #[command(subcommand)] + cmd: GroupsCmd, + }, + /// Script management. Scripts { #[command(subcommand)] @@ -401,6 +407,9 @@ enum AppsCmd { name: Option, #[arg(long)] description: Option, + /// Parent group (slug or id). Defaults to the instance root. + #[arg(long)] + group: Option, }, /// Show a single app, including the caller's role in it. @@ -423,6 +432,75 @@ enum AppsCmd { }, } +#[derive(Subcommand)] +enum GroupsCmd { + /// List all groups (flat). + Ls, + /// Render the group hierarchy as an indented tree. + Tree, + /// Create a new group. Omit `--parent` for a root-level group. + Create { + slug: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + /// Parent group (slug or id). + #[arg(long)] + parent: Option, + }, + /// Show a group with its path, subgroups, and apps. + Show { ident: String }, + /// Rename a group (name/description only — the slug is frozen). + Rename { + ident: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + }, + /// Move a group under a new parent (`--to` slug/id, or omit for root). + Reparent { + ident: String, + #[arg(long)] + to: Option, + }, + /// Delete a group. Refused (409) if non-empty unless `--recursive`, + /// which deletes child groups leaf-first (apps are never auto-deleted). + Rm { + ident: String, + #[arg(long)] + recursive: bool, + }, + /// Manage a group's members (inherited down the tree). + Members { + #[command(subcommand)] + cmd: GroupMembersCmd, + }, +} + +#[derive(Subcommand)] +enum GroupMembersCmd { + /// List a group's members. + Ls { group: String }, + /// Grant a member a role on the group. + Add { + group: String, + user_id: String, + #[arg(long, default_value = "viewer")] + role: String, + }, + /// Change a member's role. + Set { + group: String, + user_id: String, + #[arg(long)] + role: String, + }, + /// Remove a member from the group. + Rm { group: String, user_id: String }, +} + #[derive(Subcommand)] enum DomainsCmd { /// List the app's domain claims. @@ -1176,8 +1254,18 @@ async fn main() -> ExitCode { slug, name, description, + group, }, - } => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await, + } => { + cmds::apps::create( + &slug, + name.as_deref(), + description.as_deref(), + group.as_deref(), + mode, + ) + .await + } Cmd::Apps { cmd: AppsCmd::Show { ident }, } => cmds::apps::show(&ident, mode).await, @@ -1201,6 +1289,79 @@ async fn main() -> ExitCode { cmd: DomainsCmd::Rm { app, domain_id }, }, } => cmds::apps_domains::rm(&app, &domain_id).await, + Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await, + Cmd::Groups { + cmd: GroupsCmd::Tree, + } => cmds::groups::tree(mode).await, + Cmd::Groups { + cmd: + GroupsCmd::Create { + slug, + name, + description, + parent, + }, + } => { + cmds::groups::create( + &slug, + name.as_deref(), + description.as_deref(), + parent.as_deref(), + mode, + ) + .await + } + Cmd::Groups { + cmd: GroupsCmd::Show { ident }, + } => cmds::groups::show(&ident, mode).await, + Cmd::Groups { + cmd: + GroupsCmd::Rename { + ident, + name, + description, + }, + } => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await, + Cmd::Groups { + cmd: GroupsCmd::Reparent { ident, to }, + } => cmds::groups::reparent(&ident, to.as_deref(), mode).await, + Cmd::Groups { + cmd: GroupsCmd::Rm { ident, recursive }, + } => cmds::groups::rm(&ident, recursive).await, + Cmd::Groups { + cmd: + GroupsCmd::Members { + cmd: GroupMembersCmd::Ls { group }, + }, + } => cmds::groups::members_ls(&group, mode).await, + Cmd::Groups { + cmd: + GroupsCmd::Members { + cmd: + GroupMembersCmd::Add { + group, + user_id, + role, + }, + }, + } => cmds::groups::members_add(&group, &user_id, &role, mode).await, + Cmd::Groups { + cmd: + GroupsCmd::Members { + cmd: + GroupMembersCmd::Set { + group, + user_id, + role, + }, + }, + } => cmds::groups::members_set(&group, &user_id, &role, mode).await, + Cmd::Groups { + cmd: + GroupsCmd::Members { + cmd: GroupMembersCmd::Rm { group, user_id }, + }, + } => cmds::groups::members_rm(&group, &user_id).await, Cmd::Scripts { cmd: ScriptsCmd::Ls { app }, } => cmds::scripts::ls(app.as_deref(), mode).await, diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index f1c8420..9ace257 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -23,6 +23,7 @@ mod dead_letters; mod email_queue; mod enabled; mod env_overlay; +mod groups; mod init; mod invoke; mod logs; diff --git a/crates/picloud-cli/tests/common/cleanup.rs b/crates/picloud-cli/tests/common/cleanup.rs index 5e4992d..a6c8822 100644 --- a/crates/picloud-cli/tests/common/cleanup.rs +++ b/crates/picloud-cli/tests/common/cleanup.rs @@ -44,6 +44,35 @@ pub struct UserGuard { user_id: String, } +/// Deletes a group on drop (best-effort). The group must be empty by then +/// — register an `AppGuard`/child `GroupGuard` *after* this one so the +/// child drops (deletes) first, leaving an empty node here. +pub struct GroupGuard { + url: String, + token: String, + slug: String, +} + +impl GroupGuard { + pub fn new(url: &str, token: &str, slug: &str) -> Self { + Self { + url: url.to_string(), + token: token.to_string(), + slug: slug.to_string(), + } + } +} + +impl Drop for GroupGuard { + fn drop(&mut self) { + let client = reqwest::blocking::Client::new(); + let _ = client + .delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug)) + .bearer_auth(&self.token) + .send(); + } +} + impl UserGuard { pub fn new(url: &str, token: &str, user_id: &str) -> Self { Self { diff --git a/crates/picloud-cli/tests/groups.rs b/crates/picloud-cli/tests/groups.rs new file mode 100644 index 0000000..398ba9b --- /dev/null +++ b/crates/picloud-cli/tests/groups.rs @@ -0,0 +1,179 @@ +//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT, +//! reparent cycle rejection, and the headline invariant — a `group_admin` +//! on an ancestor group can act on an app it is NOT a direct member of +//! (inherited membership), and loses that access the instant the group +//! grant is revoked. + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard}; +use crate::common::member; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_tree_create_show_and_delete_restrict() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("g-acme"); + let team = common::unique_slug("g-team"); + let app = common::unique_slug("g-app"); + + // Root-level group, then a subgroup under it. + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + let _g_team = GroupGuard::new(&env.url, &env.token, &team); + common::pic_as(&env) + .args(["groups", "create", &team, "--parent", &acme]) + .assert() + .success(); + + // An app under the subgroup. + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &team]) + .assert() + .success(); + + // `groups show team` lists the app. + let out = String::from_utf8( + common::pic_as(&env) + .args(["groups", "show", &team]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + out.contains(&app), + "group detail should list its app:\n{out}" + ); + + // delete=RESTRICT: acme has a subgroup → refused. + common::pic_as(&env) + .args(["groups", "rm", &acme]) + .assert() + .failure() + .stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn reparent_into_own_descendant_is_rejected() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let parent = common::unique_slug("g-cyc-p"); + let child = common::unique_slug("g-cyc-c"); + + let _g_parent = GroupGuard::new(&env.url, &env.token, &parent); + common::pic_as(&env) + .args(["groups", "create", &parent]) + .assert() + .success(); + let _g_child = GroupGuard::new(&env.url, &env.token, &child); + common::pic_as(&env) + .args(["groups", "create", &child, "--parent", &parent]) + .assert() + .success(); + + // Moving the parent under its own child would form a cycle → refused. + common::pic_as(&env) + .args(["groups", "reparent", &parent, "--to", &child]) + .assert() + .failure() + .stderr(predicate::str::contains("409").or(predicate::str::contains("descendant"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn inherited_group_admin_can_deploy_then_revoke() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("g-inh"); + let app = common::unique_slug("g-inh-app"); + + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + + // A fresh Member with NO app membership. + let m = member::member_user(fx, &common::unique_username("inh")); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + let fixture = common::fixture_path("hello.rhai"); + + // Baseline: without any grant, deploy is forbidden. + common::pic_as(&member_env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 403")); + + // Grant group_admin on the ANCESTOR group (no app_members row). + common::pic_as(&env) + .args([ + "groups", + "members", + "add", + &acme, + &m.id, + "--role", + "app_admin", + ]) + .assert() + .success(); + + // Inherited: the member can now deploy to the app it never joined. + // (Deploy prints a KvBlock — assert on the script name + create action, + // not a prose string.) + common::pic_as(&member_env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .success() + .stdout(predicate::str::contains("hello").and(predicate::str::contains("created"))); + + // Revoke the group grant → access drops immediately (no cache lag). + common::pic_as(&env) + .args(["groups", "members", "rm", &acme, &m.id]) + .assert() + .success(); + common::pic_as(&member_env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 403")); +} From 87259391726cee3d4c3097ce2c06e62bb81066d1 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 20:15:44 +0200 Subject: [PATCH 4/5] feat(dashboard): group tree, detail page, and members tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns: - api.ts: Group/GroupDetail/GroupMember types + an api.groups client (list/get/create/update/reparent/delete + nested members CRUD); optional group selector wired into app create. - routes/groups: a collapsible tree overview (assembled from parent_id) with a New-group form, and a detail page with breadcrumb, subgroups/apps lists, a rename form (no slug field — slug is frozen), a reparent control, a delete button that surfaces the 409 RESTRICT message, and a Members tab reusing RoleChip/ConfirmModal/ActionMenu. - +layout.svelte: a Groups nav entry beside Apps. npm run check: 0 errors; npm run build: success. Co-Authored-By: Claude Opus 4.8 (1M context) --- dashboard/src/lib/api.ts | 89 ++ dashboard/src/routes/+layout.svelte | 1 + dashboard/src/routes/apps/+page.svelte | 27 +- dashboard/src/routes/groups/+page.svelte | 366 ++++++++ .../src/routes/groups/[slug]/+page.svelte | 789 ++++++++++++++++++ 5 files changed, 1270 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/routes/groups/+page.svelte create mode 100644 dashboard/src/routes/groups/[slug]/+page.svelte diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index a8b111a..78a50a1 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -50,6 +50,47 @@ export interface App { export type AppRole = 'app_admin' | 'editor' | 'viewer'; +export interface Group { + id: string; + parent_id: string | null; + slug: string; + name: string; + description: string | null; + structure_version: number; + created_at: string; + updated_at: string; +} + +export interface GroupDetail extends Group { + /** Root → … → this node breadcrumb. */ + path: Group[]; + subgroups: Group[]; + apps: App[]; +} + +export interface GroupMember { + user_id: string; + username: string; + email: string | null; + instance_role: InstanceRole; + is_active: boolean; + role: AppRole; + created_at: string; +} + +export interface CreateGroupInput { + slug: string; + name: string; + description?: string | null; + /** Parent group slug or id; omit (or null) for a root group. */ + parent?: string | null; +} + +export interface PatchGroupInput { + name?: string; + description?: string | null; +} + export type DomainShape = 'exact' | 'wildcard' | 'parameterized'; export interface AppDomain { @@ -89,6 +130,8 @@ export interface CreateAppInput { name: string; description?: string | null; force_takeover?: boolean; + /** Parent group slug or id; omit for the root group. */ + group?: string | null; } export interface PatchAppInput { @@ -778,6 +821,52 @@ export const api = { ) }, + groups: { + list: () => adminRequest('/api/v1/admin/groups'), + get: (idOrSlug: string) => + adminRequest(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`), + create: (input: CreateGroupInput) => + adminRequest('/api/v1/admin/groups', { + method: 'POST', + body: JSON.stringify(input) + }), + update: (idOrSlug: string, input: PatchGroupInput) => + adminRequest(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, { + method: 'PATCH', + body: JSON.stringify(input) + }), + reparent: (idOrSlug: string, parent: string | null) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`, + { method: 'POST', body: JSON.stringify({ parent }) } + ), + delete: (idOrSlug: string) => + adminRequest(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, { + method: 'DELETE' + }), + members: { + list: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members` + ), + grant: (idOrSlug: string, input: GrantAppMemberInput) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`, + { method: 'POST', body: JSON.stringify(input) } + ), + update: (idOrSlug: string, userId: string, role: AppRole) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`, + { method: 'PATCH', body: JSON.stringify({ role }) } + ), + remove: (idOrSlug: string, userId: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`, + { method: 'DELETE' } + ) + } + }, + domains: { listForApp: (idOrSlug: string) => adminRequest( diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index 7f1905f..1a1b2e3 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -55,6 +55,7 @@ PiCloud