From a432091191f81717de002f032480a9e2866e4653 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 19:59:00 +0200 Subject: [PATCH] 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,