Merge: groups Phase 2 — org/RBAC/UI container (migration 0047)

Adds the GitLab-style single-parent group tree above apps: migration
0047 (groups + group_members + apps.group_id NOT NULL/RESTRICT with
single-root backfill), tree repos with an ancestor-walk cycle guard
under a coarse structural advisory lock, slug-freeze and structure_version,
hierarchy-aware RBAC (effective role = MAX across app membership + every
ancestor group, resolved live so revocation is immediate), new
Group{Read,Write,Admin}/InstanceCreateGroup caps that bound API keys
cannot obtain, plus `pic groups` and a dashboard group tree/detail/members.

Verified before merge: fmt/clippy(-D warnings)/check clean; manager-core
378 unit tests pass (incl. hierarchy authz + group repos); dashboard
npm run check 0 errors; check-versioning OK (47 migrations sequential).
New CLI groups journeys pass against a live DB — cycle guard
(reparent-into-descendant rejected), delete=RESTRICT, and
inherited-group-admin deploy + live revoke. The 6 failing logs/scripts/roles
journeys are the same pre-existing format drift on main, not regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:26:21 +02:00
31 changed files with 3996 additions and 57 deletions

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@
//! external user-facing label. //! external user-facing label.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId}; use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
/// Things a caller can attempt to do. Each app-scoped variant carries /// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute /// the `AppId` of the resource the action targets — handlers compute
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
pub enum Capability { pub enum Capability {
/// Create a new app. Owner / admin only. /// Create a new app. Owner / admin only.
InstanceCreateApp, InstanceCreateApp,
/// Create a new group (root-level). Owner / admin only — a Member
/// creates subgroups under a group they group-admin (gated by
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
InstanceCreateGroup,
/// Read group metadata + list its subgroups/apps. Viewer+ on the
/// group (inherited from any ancestor); implicit for admin / owner.
GroupRead(GroupId),
/// Rename / edit group metadata, move apps into it. Editor+ on the
/// group.
GroupWrite(GroupId),
/// Group settings: delete, reparent, manage group members. group_admin
/// on the group (inherited from any ancestor).
GroupAdmin(GroupId),
/// Create / update / delete admin_users rows (other than self /// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin. /// password change, which is a separate flow). Owner / admin.
InstanceManageUsers, InstanceManageUsers,
@@ -154,9 +167,16 @@ impl Capability {
#[must_use] #[must_use]
pub const fn app_id(self) -> Option<AppId> { pub const fn app_id(self) -> Option<AppId> {
match self { match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { Self::InstanceCreateApp
None | Self::InstanceManageUsers
} | Self::InstanceManageSettings
| Self::InstanceCreateGroup
// Group-scoped caps carry a GroupId, not an AppId. They return
// None here so a bound API key (which can only target its one
// app) is denied group management at the binding layer.
| Self::GroupRead(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => None,
Self::AppRead(id) Self::AppRead(id)
| Self::AppWriteScript(id) | Self::AppWriteScript(id)
| Self::AppWriteRoute(id) | Self::AppWriteRoute(id)
@@ -193,15 +213,17 @@ impl Capability {
#[must_use] #[must_use]
pub const fn required_scope(self) -> Scope { pub const fn required_scope(self) -> Scope {
match self { match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => { Self::InstanceCreateApp
Scope::InstanceAdmin | Self::InstanceManageUsers
} | Self::InstanceManageSettings
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
Self::AppRead(_) Self::AppRead(_)
| Self::AppKvRead(_) | Self::AppKvRead(_)
| Self::AppDocsRead(_) | Self::AppDocsRead(_)
| Self::AppFilesRead(_) | Self::AppFilesRead(_)
| Self::AppSecretsRead(_) | Self::AppSecretsRead(_)
| Self::AppUsersRead(_) => Scope::ScriptRead, | Self::AppUsersRead(_)
| Self::GroupRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) Self::AppWriteScript(_)
| Self::AppKvWrite(_) | Self::AppKvWrite(_)
| Self::AppDocsWrite(_) | Self::AppDocsWrite(_)
@@ -219,7 +241,9 @@ impl Capability {
Self::AppAdmin(_) Self::AppAdmin(_)
| Self::AppManageTriggers(_) | Self::AppManageTriggers(_)
| Self::AppDeadLetterManage(_) | Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_) => Scope::AppAdmin, | Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead, Self::AppLogRead(_) => Scope::LogRead,
} }
} }
@@ -230,11 +254,41 @@ impl Capability {
/// means unit tests can stub it. /// means unit tests can stub it.
#[async_trait] #[async_trait]
pub trait AuthzRepo: Send + Sync { pub trait AuthzRepo: Send + Sync {
/// Direct `app_members` row for (user, app). The single-row lookup
/// used by member-management surfaces and as the fallback below.
async fn membership( async fn membership(
&self, &self,
user_id: UserId, user_id: UserId,
app_id: AppId, app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>; ) -> Result<Option<AppRole>, AuthzError>;
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
/// the app's own `app_members` row folded with every `group_members`
/// row on any ancestor group, max-by-authority. This is what `can()`
/// consults so a `group_admin` on an ancestor is implicitly app_admin
/// on the app.
///
/// Default = direct membership only (no inheritance), so the many test
/// stubs that model no group tree keep their existing behavior; the
/// Postgres repo overrides this with an ancestor-walking CTE.
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
self.membership(user_id, app_id).await
}
/// Highest effective role on a *group* node — the group's own
/// ancestor walk over `group_members`. Gates the group-management
/// capabilities. Default = no grant; the Postgres repo overrides it.
async fn effective_group_role(
&self,
_user_id: UserId,
_group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
} }
/// Repo errors surface here so handlers can map them to 500 without /// Repo errors surface here so handlers can map them to 500 without
@@ -353,7 +407,20 @@ async fn role_grants(
match principal.instance_role { match principal.instance_role {
InstanceRole::Owner => Ok(true), InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)), InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await, InstanceRole::Member => match cap {
// Group-management caps resolve against the group ancestor
// walk (a group_admin on an ancestor is implicitly admin of
// the descendant group). Routed before member_grants because
// group caps carry no app_id.
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
// the handler, which routes through the arm above.)
Capability::InstanceCreateGroup => Ok(false),
_ => member_grants(repo, principal.user_id, cap).await,
},
} }
} }
@@ -377,12 +444,41 @@ async fn member_grants(
let Some(app_id) = cap.app_id() else { let Some(app_id) = cap.app_id() else {
return Ok(false); return Ok(false);
}; };
let Some(role) = repo.membership(user_id, app_id).await? else { // Effective (inherited) role: the app's own membership folded with any
// ancestor group membership. A group_admin on an ancestor group is
// implicitly app_admin here.
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
return Ok(false); return Ok(false);
}; };
Ok(role_satisfies(role, cap)) Ok(role_satisfies(role, cap))
} }
/// Member-path resolution for the group-management capabilities. Resolves
/// the caller's effective role on the group (ancestor walk over
/// `group_members`) and checks it covers the requested group action.
async fn group_member_grants(
repo: &dyn AuthzRepo,
user_id: UserId,
cap: Capability,
group_id: GroupId,
) -> Result<bool, AuthzError> {
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
return Ok(false);
};
Ok(group_role_satisfies(role, cap))
}
/// Does the effective group `AppRole` cover the group capability?
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
Capability::GroupRead(_) => true, // any role can read
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
_ => false,
}
}
/// Does the per-app `AppRole` cover the capability? Viewer can read; /// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings, /// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so /// domain claims, and delete. Roles form a strict subset chain, so
@@ -471,16 +567,61 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database. /// In-memory `AuthzRepo` so the unit tests don't need a database. Models
/// direct app memberships PLUS a group tree (app→group, group→parent)
/// and group memberships, so the hierarchy-aware resolution can be
/// exercised without Postgres — mirroring the recursive-CTE behavior.
#[derive(Default)] #[derive(Default)]
struct InMemoryAuthzRepo { struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>, memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
app_group: Mutex<HashMap<AppId, GroupId>>,
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
} }
impl InMemoryAuthzRepo { impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) { async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role); self.memberships.lock().await.insert((user, app), role);
} }
/// Register a group node and its parent (`None` = root).
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
self.group_parent.lock().await.insert(group, parent);
}
/// Place an app under a group.
async fn put_app(&self, app: AppId, group: GroupId) {
self.app_group.lock().await.insert(app, group);
}
/// Grant a group-level role.
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
self.group_memberships
.lock()
.await
.insert((user, group), role);
}
/// Fold every ancestor group membership starting at `group`,
/// max-by-authority, into `acc`.
async fn fold_group_chain(
&self,
user_id: UserId,
mut group: Option<GroupId>,
mut acc: Option<AppRole>,
) -> Option<AppRole> {
let memberships = self.group_memberships.lock().await;
let parents = self.group_parent.lock().await;
let mut hops = 0u32;
while let Some(g) = group {
if let Some(r) = memberships.get(&(user_id, g)).copied() {
acc = Some(acc.map_or(r, |a| a.max(r)));
}
hops += 1;
if hops > 64 {
break;
}
group = parents.get(&g).copied().flatten();
}
acc
}
} }
#[async_trait] #[async_trait]
@@ -497,6 +638,29 @@ mod tests {
.get(&(user_id, app_id)) .get(&(user_id, app_id))
.copied()) .copied())
} }
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
let direct = self
.memberships
.lock()
.await
.get(&(user_id, app_id))
.copied();
let start = self.app_group.lock().await.get(&app_id).copied();
Ok(self.fold_group_chain(user_id, start, direct).await)
}
async fn effective_group_role(
&self,
user_id: UserId,
group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
}
} }
fn principal(role: InstanceRole) -> Principal { fn principal(role: InstanceRole) -> Principal {
@@ -857,12 +1021,183 @@ mod tests {
); );
} }
// ------------------------------------------------------------------
// Hierarchy-aware RBAC (Phase 2 groups)
// ------------------------------------------------------------------
#[tokio::test]
async fn group_admin_on_ancestor_is_implicit_app_admin() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
let app = AppId::new();
repo.add_group(acme, None).await;
repo.put_app(app, acme).await;
let p = principal(InstanceRole::Member);
// No app_members row — authority comes purely from the group.
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppAdmin(app),
] {
assert!(
can(&repo, &p, cap).await.unwrap().is_allow(),
"inherited group_admin denied {cap:?}"
);
}
}
#[tokio::test]
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
let app = AppId::new();
repo.add_group(acme, None).await;
repo.put_app(app, acme).await;
let p = principal(InstanceRole::Member);
// Direct viewer on the app, app_admin via the ancestor group:
// the higher (app_admin) wins.
repo.grant(p.user_id, app, AppRole::Viewer).await;
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
assert!(can(&repo, &p, Capability::AppAdmin(app))
.await
.unwrap()
.is_allow());
}
#[tokio::test]
async fn group_role_inherits_down_a_multi_level_tree() {
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
let app = AppId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
repo.put_app(app, team).await;
// Editor two levels up flows down to the app as editor.
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, root, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
"editor must not get app_admin"
);
}
#[tokio::test]
async fn group_membership_grants_no_instance_capabilities() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
repo.add_group(acme, None).await;
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceCreateGroup,
Capability::InstanceManageUsers,
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Deny,
"group_admin must not grant instance cap {cap:?}"
);
}
}
#[tokio::test]
async fn group_admin_walks_ancestors_for_group_caps() {
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
// group_admin at root ⇒ admin of the descendant group.
assert!(can(&repo, &p, Capability::GroupAdmin(team))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::GroupWrite(team))
.await
.unwrap()
.is_allow());
// An unrelated member gets nothing.
let outsider = principal(InstanceRole::Member);
assert_eq!(
can(&repo, &outsider, Capability::GroupRead(team))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn admin_implicitly_manages_the_whole_group_tree() {
let repo = InMemoryAuthzRepo::default();
let g = GroupId::new();
let p = principal(InstanceRole::Admin);
for cap in [
Capability::InstanceCreateGroup,
Capability::GroupRead(g),
Capability::GroupWrite(g),
Capability::GroupAdmin(g),
] {
assert!(
can(&repo, &p, cap).await.unwrap().is_allow(),
"admin denied group cap {cap:?}"
);
}
}
#[tokio::test]
async fn bound_key_cannot_manage_groups() {
let repo = InMemoryAuthzRepo::default();
let g = GroupId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::AppAdmin]),
app_binding: Some(AppId::new()),
};
// Group caps carry no app_id, so a bound key is denied at the
// binding layer regardless of role/scope.
assert_eq!(
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
Decision::Deny
);
}
#[test]
fn app_role_max_is_authority_ordered() {
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
}
#[test] #[test]
fn capability_app_id_extraction() { fn capability_app_id_extraction() {
let app = AppId::new(); let app = AppId::new();
assert_eq!(Capability::InstanceCreateApp.app_id(), None); assert_eq!(Capability::InstanceCreateApp.app_id(), None);
assert_eq!(Capability::AppRead(app).app_id(), Some(app)); assert_eq!(Capability::AppRead(app).app_id(), Some(app));
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app)); assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
// Group caps are not app-scoped.
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
} }
#[test] #[test]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{ use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, 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 reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -149,6 +150,144 @@ impl Client {
decode_status(resp).await 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<Vec<Group>> {
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<GroupDetailDto> {
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<Group> {
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<Group> {
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<Group> {
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<Vec<AppMemberDto>> {
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<AppMemberDto> {
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<AppMemberDto> {
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 /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design). /// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> { pub async fn scripts_delete(&self, id: &str) -> Result<()> {
@@ -1049,6 +1188,31 @@ pub struct CreateAppBody<'a> {
pub name: &'a str, pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>, 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<Group>,
pub subgroups: Vec<Group>,
pub apps: Vec<App>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View File

@@ -31,6 +31,7 @@ pub async fn create(
slug: &str, slug: &str,
name: Option<&str>, name: Option<&str>,
description: Option<&str>, description: Option<&str>,
group: Option<&str>,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
@@ -39,6 +40,7 @@ pub async fn create(
slug, slug,
name: name.unwrap_or(slug), name: name.unwrap_or(slug),
description, description,
group,
}; };
let app = client.apps_create(&body).await?; let app = client.apps_create(&body).await?;
// Emit the created object so `--output json` callers can capture the // Emit the created object so `--output json` callers can capture the

View File

@@ -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<Option<_>, 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<Option<picloud_shared::GroupId>, Vec<&Group>>,
parent: Option<picloud_shared::GroupId>,
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::<Vec<_>>()
.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::<Vec<_>>()
.join(", "),
)
.field(
"apps",
detail
.apps
.iter()
.map(|a| a.slug.as_str())
.collect::<Vec<_>>()
.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 <slug>`. 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> {
AppRole::from_db_str(role)
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
}

View File

@@ -6,6 +6,7 @@ pub mod apps_domains;
pub mod config; pub mod config;
pub mod dead_letters; pub mod dead_letters;
pub mod files; pub mod files;
pub mod groups;
pub mod init; pub mod init;
pub mod kv; pub mod kv;
pub mod login; pub mod login;

View File

@@ -51,6 +51,12 @@ enum Cmd {
cmd: AppsCmd, cmd: AppsCmd,
}, },
/// Group (org-tree) management.
Groups {
#[command(subcommand)]
cmd: GroupsCmd,
},
/// Script management. /// Script management.
Scripts { Scripts {
#[command(subcommand)] #[command(subcommand)]
@@ -401,6 +407,9 @@ enum AppsCmd {
name: Option<String>, name: Option<String>,
#[arg(long)] #[arg(long)]
description: Option<String>, description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root.
#[arg(long)]
group: Option<String>,
}, },
/// Show a single app, including the caller's role in it. /// 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<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id).
#[arg(long)]
parent: Option<String>,
},
/// 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<String>,
#[arg(long)]
description: Option<String>,
},
/// Move a group under a new parent (`--to` slug/id, or omit for root).
Reparent {
ident: String,
#[arg(long)]
to: Option<String>,
},
/// 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)] #[derive(Subcommand)]
enum DomainsCmd { enum DomainsCmd {
/// List the app's domain claims. /// List the app's domain claims.
@@ -1176,8 +1254,18 @@ async fn main() -> ExitCode {
slug, slug,
name, name,
description, 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::Apps {
cmd: AppsCmd::Show { ident }, cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await, } => cmds::apps::show(&ident, mode).await,
@@ -1201,6 +1289,79 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id }, cmd: DomainsCmd::Rm { app, domain_id },
}, },
} => cmds::apps_domains::rm(&app, &domain_id).await, } => 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::Scripts {
cmd: ScriptsCmd::Ls { app }, cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await, } => cmds::scripts::ls(app.as_deref(), mode).await,

View File

@@ -23,6 +23,7 @@ mod dead_letters;
mod email_queue; mod email_queue;
mod enabled; mod enabled;
mod env_overlay; mod env_overlay;
mod groups;
mod init; mod init;
mod invoke; mod invoke;
mod logs; mod logs;

View File

@@ -44,6 +44,35 @@ pub struct UserGuard {
user_id: String, 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 { impl UserGuard {
pub fn new(url: &str, token: &str, user_id: &str) -> Self { pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self { Self {

View File

@@ -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"));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,47 @@ export interface App {
export type AppRole = 'app_admin' | 'editor' | 'viewer'; 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 type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain { export interface AppDomain {
@@ -89,6 +130,8 @@ export interface CreateAppInput {
name: string; name: string;
description?: string | null; description?: string | null;
force_takeover?: boolean; force_takeover?: boolean;
/** Parent group slug or id; omit for the root group. */
group?: string | null;
} }
export interface PatchAppInput { export interface PatchAppInput {
@@ -778,6 +821,52 @@ export const api = {
) )
}, },
groups: {
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
get: (idOrSlug: string) =>
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateGroupInput) =>
adminRequest<Group>('/api/v1/admin/groups', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchGroupInput) =>
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
reparent: (idOrSlug: string, parent: string | null) =>
adminRequest<Group>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
{ method: 'POST', body: JSON.stringify({ parent }) }
),
delete: (idOrSlug: string) =>
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
}),
members: {
list: (idOrSlug: string) =>
adminRequest<GroupMember[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
),
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
update: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
}
},
domains: { domains: {
listForApp: (idOrSlug: string) => listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>( adminRequest<AppDomain[]>(

View File

@@ -55,6 +55,7 @@
<a href={base + '/'} class="brand">PiCloud</a> <a href={base + '/'} class="brand">PiCloud</a>
<nav> <nav>
<a href={base + '/apps'}>Apps</a> <a href={base + '/apps'}>Apps</a>
<a href={base + '/groups'}>Groups</a>
{#if user && user.instance_role !== 'member'} {#if user && user.instance_role !== 'member'}
<a href={base + '/users'}>Users</a> <a href={base + '/users'}>Users</a>
{/if} {/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { base } from '$app/paths'; import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api'; import { api, ApiError, type App, type Group } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify'; import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities'; import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth'; import { currentUser } from '$lib/auth';
@@ -36,6 +36,17 @@
let createSlug = $state(''); let createSlug = $state('');
let createName = $state(''); let createName = $state('');
let createDescription = $state(''); let createDescription = $state('');
// Optional parent group for the new app (defaults to root). Loaded
// lazily; failure leaves the picker empty (root-only).
let createGroup = $state('');
let groups = $state<Group[]>([]);
async function loadGroups() {
try {
groups = await api.groups.list();
} catch {
groups = [];
}
}
// Auto-derive slug from name until the user takes manual control of // Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the // the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX. // auto-derive resumes — matches the GitLab project-create UX.
@@ -69,6 +80,7 @@
listError = null; listError = null;
try { try {
apps = await api.apps.list(); apps = await api.apps.list();
void loadGroups();
if (apps && apps.length > 0) { if (apps && apps.length > 0) {
void loadDlCounts(apps); void loadDlCounts(apps);
} }
@@ -84,6 +96,7 @@
createSlug = ''; createSlug = '';
createName = ''; createName = '';
createDescription = ''; createDescription = '';
createGroup = '';
createError = null; createError = null;
createHistoricalConflict = null; createHistoricalConflict = null;
slugTouched = false; slugTouched = false;
@@ -99,7 +112,8 @@
slug: createSlug.trim(), slug: createSlug.trim(),
name: createName.trim(), name: createName.trim(),
description: createDescription.trim() || null, description: createDescription.trim() || null,
force_takeover: forceTakeover || undefined force_takeover: forceTakeover || undefined,
group: createGroup.trim() || undefined
}); });
showCreate = false; showCreate = false;
resetCreate(); resetCreate();
@@ -172,6 +186,15 @@
<span>Description</span> <span>Description</span>
<input bind:value={createDescription} placeholder="optional" /> <input bind:value={createDescription} placeholder="optional" />
</label> </label>
<label>
<span>Group (optional)</span>
<select bind:value={createGroup}>
<option value="">— root —</option>
{#each groups as g (g.id)}
<option value={g.slug}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
{#if createHistoricalConflict} {#if createHistoricalConflict}
<div class="warning"> <div class="warning">
<strong>Slug previously redirected.</strong> <strong>Slug previously redirected.</strong>

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Group } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
// Group creation mirrors app creation's gate — owner/admin only.
const canCreate = $derived(canCreateApp(me));
let groups = $state<Group[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(true);
// Tree assembly: index children by parent_id so we can render the
// flat list as a nested tree. Roots have parent_id === null.
interface TreeNode {
group: Group;
children: TreeNode[];
}
const tree = $derived.by<TreeNode[]>(() => {
if (!groups) return [];
const byParent = new Map<string | null, Group[]>();
for (const g of groups) {
const key = g.parent_id;
const bucket = byParent.get(key) ?? [];
bucket.push(g);
byParent.set(key, bucket);
}
const build = (parentId: string | null): TreeNode[] =>
(byParent.get(parentId) ?? [])
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => ({ group, children: build(group.id) }));
return build(null);
});
// Collapse state, keyed by group id. Default: expanded.
let collapsed = $state<Record<string, boolean>>({});
function toggle(id: string) {
collapsed = { ...collapsed, [id]: !collapsed[id] };
}
// Create form
let showCreate = $state(false);
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
let createParent = $state('');
let slugTouched = $state(false);
let creating = $state(false);
let createError = $state<string | null>(null);
function onNameInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
createName = value;
if (!slugTouched) {
createSlug = slugify(value);
}
}
function onSlugInput(event: Event) {
const raw = (event.target as HTMLInputElement).value;
const normalized = slugify(raw);
createSlug = normalized;
if (raw !== normalized) {
(event.target as HTMLInputElement).value = normalized;
}
slugTouched = normalized.length > 0;
}
async function load() {
loading = true;
listError = null;
try {
groups = await api.groups.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
groups = null;
} finally {
loading = false;
}
}
function resetCreate() {
createSlug = '';
createName = '';
createDescription = '';
createParent = '';
createError = null;
slugTouched = false;
}
async function submitCreate(event: Event) {
event.preventDefault();
creating = true;
createError = null;
try {
await api.groups.create({
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
parent: createParent.trim() || undefined
});
showCreate = false;
resetCreate();
await load();
} catch (e) {
createError =
e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
$effect(() => {
void load();
});
</script>
<section>
<header class="page-header">
<h1>Groups</h1>
{#if canCreate}
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New group'}
</button>
{/if}
</header>
{#if showCreate && canCreate}
<form class="create-form" onsubmit={submitCreate}>
<div class="row">
<label>
<span>Name</span>
<input value={createName} oninput={onNameInput} required placeholder="My Group" />
</label>
<label>
<span>Slug</span>
<input
value={createSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9][a-z0-9-]*"
maxlength={SLUG_MAX}
placeholder="my-group"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
</label>
</div>
<label>
<span>Parent group (optional)</span>
<select bind:value={createParent}>
<option value="">— root —</option>
{#each groups ?? [] as g (g.id)}
<option value={g.slug}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
{#if createError}
<div class="error">{createError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create group'}
</button>
</div>
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load groups.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if groups && groups.length === 0}
<p class="muted">No groups yet. Create one above to get started.</p>
{:else if groups}
<ul class="tree">
{#each tree as node (node.group.id)}
{@render branch(node, 0)}
{/each}
</ul>
{/if}
</section>
{#snippet branch(node: TreeNode, depth: number)}
<li>
<div class="node" style="padding-left: {depth * 1.25}rem">
{#if node.children.length > 0}
<button
type="button"
class="twisty"
aria-label={collapsed[node.group.id] ? 'Expand' : 'Collapse'}
onclick={() => toggle(node.group.id)}
>
{collapsed[node.group.id] ? '▸' : '▾'}
</button>
{:else}
<span class="twisty-spacer"></span>
{/if}
<a href="{base}/groups/{node.group.slug}">
<strong>{node.group.name}</strong>
<span class="muted">/{node.group.slug}</span>
</a>
</div>
{#if node.children.length > 0 && !collapsed[node.group.id]}
<ul>
{#each node.children as child (child.group.id)}
{@render branch(child, depth + 1)}
{/each}
</ul>
{/if}
</li>
{/snippet}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.tree {
list-style: none;
padding: 0;
margin: 0;
}
.tree ul {
list-style: none;
padding: 0;
margin: 0;
}
.node {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.node a {
flex: 1;
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.55rem 0.75rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.node a:hover {
background: #283549;
}
.twisty {
background: transparent;
color: #94a3b8;
border: none;
padding: 0;
width: 1.25rem;
font-size: 0.85rem;
font-weight: 400;
cursor: pointer;
flex: none;
}
.twisty-spacer {
width: 1.25rem;
flex: none;
}
</style>

View File

@@ -0,0 +1,789 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type AdminDto,
type Group,
type GroupDetail,
type GroupMember,
type AppRole
} from '$lib/api';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canCreateApp } from '$lib/capabilities';
const me = $derived($currentUser);
// Mirror app-admin gating: instance owner/admin manage groups, their
// members, structure, and deletion. The backend is the ground truth.
const canManage = $derived(canCreateApp(me));
type Tab = 'overview' | 'members' | 'settings';
const isValidTab = (s: string | null): s is Tab =>
s === 'overview' || s === 'members' || s === 'settings';
let activeTab = $derived.by<Tab>(() => {
const qp = page.url.searchParams.get('tab');
return isValidTab(qp) ? qp : 'overview';
});
let slug = $derived(page.params.slug ?? '');
let group = $state<GroupDetail | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
// All groups — used for the reparent + rename forms and to offer a
// parent picker that excludes the node itself.
let allGroups = $state<Group[]>([]);
// Settings (rename — name/description only; slug is frozen).
let editName = $state('');
let editDescription = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
// Reparent.
let reparentTarget = $state('');
let reparenting = $state(false);
let reparentError = $state<string | null>(null);
// Delete.
let confirmingDelete = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
// Members.
let members = $state<GroupMember[]>([]);
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<GroupMember | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadGroup() {
loading = true;
loadError = null;
try {
const fetched = await api.groups.get(slug);
group = fetched;
editName = fetched.name;
editDescription = fetched.description ?? '';
reparentTarget = fetched.parent_id ?? '';
const loaders: Promise<unknown>[] = [loadAllGroups()];
if (canManage) {
loaders.push(loadMembers(slug), loadEligibleUsers());
}
await Promise.all(loaders);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadAllGroups() {
try {
allGroups = await api.groups.list();
} catch {
allGroups = [];
}
}
async function loadMembers(idOrSlug: string) {
try {
members = await api.groups.members.list(idOrSlug);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
eligibleUsers = all.filter((u) => u.is_active && u.instance_role === 'member');
} catch (e) {
eligibleUsers = [];
eligibleLoadError =
e instanceof ApiError && e.status === 403
? 'Only instance owners/admins can browse the user directory to invite new members.'
: e instanceof Error
? e.message
: String(e);
}
}
const eligibleAfterFilter = $derived(
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
);
// Reparent picker options: every group except this node (a group
// can't parent itself; the backend also rejects descendant cycles
// with a 409, surfaced below).
const reparentOptions = $derived(allGroups.filter((g) => g.id !== group?.id));
async function saveSettings(event: Event) {
event.preventDefault();
if (!group) return;
savingSettings = true;
settingsError = null;
try {
const updated = await api.groups.update(group.id, {
name: editName.trim() !== group.name ? editName.trim() : undefined,
description:
editDescription !== (group.description ?? '')
? editDescription || null
: undefined
});
group = { ...group, name: updated.name, description: updated.description };
} catch (e) {
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
async function submitReparent(event: Event) {
event.preventDefault();
if (!group) return;
reparenting = true;
reparentError = null;
try {
await api.groups.reparent(group.id, reparentTarget.trim() || null);
await loadGroup();
} catch (e) {
reparentError =
e instanceof ApiError && e.status === 409
? e.message
: e instanceof Error
? e.message
: String(e);
} finally {
reparenting = false;
}
}
function askDelete() {
deleteError = null;
confirmingDelete = true;
}
async function confirmDelete() {
if (!group) return;
deleting = true;
deleteError = null;
try {
await api.groups.delete(group.id);
await goto(`${base}/groups`);
} catch (e) {
// 409 RESTRICT: the group still has subgroups or apps. Surface
// the server's message cleanly rather than a bare status code.
deleteError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false;
}
}
async function submitAddMember(event: Event) {
event.preventDefault();
if (!group || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.groups.members.grant(group.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(group.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: GroupMember, role: AppRole) {
if (!group || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.groups.members.update(group.id, member.user_id, role);
await loadMembers(group.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: GroupMember) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!group || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
await api.groups.members.remove(group.id, memberToRemove.user_id);
memberToRemove = null;
await loadMembers(group.id);
} catch (e) {
removeMemberError = e instanceof Error ? e.message : String(e);
} finally {
removingMember = false;
}
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
$effect(() => {
void slug;
void loadGroup();
});
// A viewer following a stale link to a manage-only tab gets bounced
// to the overview. The backend still 403s the underlying calls.
$effect(() => {
if (!canManage && (activeTab === 'settings' || activeTab === 'members')) {
void goto(`${base}/groups/${slug}?tab=overview`, { replaceState: true });
}
});
</script>
<header class="page-head">
<div class="breadcrumb">
<a href="{base}/groups">Groups</a>
{#if group}
{#each group.path as p (p.id)}
<span aria-hidden="true">/</span>
{#if p.id === group.id}
<code>{p.slug}</code>
{:else}
<a href="{base}/groups/{p.slug}">{p.name}</a>
{/if}
{/each}
{:else}
<span aria-hidden="true">/</span>
<code>{slug}</code>
{/if}
</div>
{#if group}
<h1>{group.name}</h1>
{#if group.description}<p class="muted">{group.description}</p>{/if}
{/if}
</header>
{#if group}
<nav class="tabbar">
<a href="{base}/groups/{slug}?tab=overview" class:active={activeTab === 'overview'}>Overview</a>
{#if canManage}
<a href="{base}/groups/{slug}?tab=members" class:active={activeTab === 'members'}>Members</a>
<a href="{base}/groups/{slug}?tab=settings" class:active={activeTab === 'settings'}>Settings</a>
{/if}
</nav>
{#if activeTab === 'overview'}
<section>
<h2>Subgroups</h2>
{#if group.subgroups.length === 0}
<p class="muted">No subgroups.</p>
{:else}
<ul class="list">
{#each group.subgroups as sub (sub.id)}
<li>
<a href="{base}/groups/{sub.slug}">
<div class="primary">
<strong>{sub.name}</strong>
<span class="muted">/{sub.slug}</span>
</div>
<div class="secondary muted">{sub.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
<h2>Apps</h2>
{#if group.apps.length === 0}
<p class="muted">No apps in this group.</p>
{:else}
<ul class="list">
{#each group.apps as app (app.id)}
<li>
<a href="{base}/apps/{app.slug}">
<div class="primary">
<strong>{app.name}</strong>
<span class="muted">/{app.slug}</span>
</div>
<div class="secondary muted">{app.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canManage}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this group. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them group access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>Group role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled: m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'app_admin')
},
{
label: 'Make editor',
disabled: m.role === 'editor' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'editor')
},
{
label: 'Make viewer',
disabled: m.role === 'viewer' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'viewer')
},
{
label: 'Remove from group',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'settings' && canManage}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={saveSettings}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<p class="muted small">
The slug <code>/{group.slug}</code> is frozen and cannot be changed.
</p>
{#if settingsError}
<div class="error">{settingsError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
<h2>Move group</h2>
<form class="create-form" onsubmit={submitReparent}>
<label>
<span>Parent group</span>
<select bind:value={reparentTarget}>
<option value="">— root —</option>
{#each reparentOptions as g (g.id)}
<option value={g.id}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
{#if reparentError}
<div class="error">{reparentError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={reparenting}>
{reparenting ? 'Moving…' : 'Move group'}
</button>
</div>
</form>
<div class="danger-zone">
<h3>Delete group</h3>
<p class="muted">
Removes this group. The group must be empty first — it can't have any
subgroups or apps.
</p>
<button type="button" class="danger" onclick={askDelete}>Delete group</button>
</div>
</section>
{/if}
{#if confirmingDelete}
<ConfirmModal
title="Delete group “{group.name}”"
variant="danger"
confirmLabel="Delete group"
busyLabel="Deleting…"
busy={deleting}
onConfirm={confirmDelete}
onCancel={() => (confirmingDelete = false)}
>
<p>
This permanently removes the group <strong>{group.name}</strong>. The
group must be empty — if it still has subgroups or apps, the delete is
rejected and the reason appears below.
</p>
{#if deleteError}
<p class="modal-error">{deleteError}</p>
{/if}
</ConfirmModal>
{/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {group.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
group. Their other memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{:else if loading}
<p class="muted">Loading…</p>
{:else if loadError}
<p class="error">{loadError}</p>
{/if}
<style>
.page-head {
margin-bottom: 1rem;
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
font-size: 0.875rem;
color: var(--text-muted);
}
.breadcrumb a {
color: var(--color-link);
text-decoration: none;
}
.breadcrumb code {
background: var(--bg-elevated);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
h1 {
margin: 0.25rem 0 0.25rem;
font-size: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin: 1.5rem 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.tabbar {
display: flex;
gap: 1.25rem;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.5rem;
}
.tabbar a {
color: #94a3b8;
text-decoration: none;
font-size: 0.9rem;
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
}
.tabbar a:hover {
color: #e2e8f0;
}
.tabbar a.active {
color: #e2e8f0;
border-bottom-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.small {
font-size: 0.85rem;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.modal-error {
color: #fca5a5;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.create-form input,
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -853,6 +853,28 @@ Resolved items now live inline next to their topic. What genuinely remains:
RESTRICT**, reparent/rename with the **ancestor-walk cycle guard** + **slug-freeze** + RESTRICT**, reparent/rename with the **ancestor-walk cycle guard** + **slug-freeze** +
**tree-structure version**, §5.6), inherited membership, hierarchy-aware `can`, UI grouping, the §9 **tree-structure version**, §5.6), inherited membership, hierarchy-aware `can`, UI grouping, the §9
backfill. No shared resources yet — cheap, no data-plane schema change. backfill. No shared resources yet — cheap, no data-plane schema change.
> **Status (Phase 2): ✅ shipped.** Migration `0047_groups.sql` adds `groups` (self-FK
> `parent_id` ON DELETE RESTRICT, instance-global frozen `slug`, `structure_version`, inert
> `owner_project` §7 seam) + `group_members` (same `app_admin|editor|viewer` literals as
> `app_members`) + `apps.group_id` (NOT NULL, RESTRICT) with the §9 single-root backfill. The
> tree lives in `group_repo` (reparent: ancestor-walk cycle guard under a coarse instance-wide
> structural advisory lock, slug-freeze, `structure_version` bump; delete=RESTRICT) and
> `group_members_repo`. **Hierarchy-aware RBAC** (§5.3): `AuthzRepo::effective_app_role` /
> `effective_group_role` resolve the highest role across the app's own membership + every ancestor
> group via one depth-bounded recursive CTE; `authz::can`'s Member path folds inherited group
> roles, so a `group_admin` on any ancestor is implicitly app_admin beneath it — resolved **live**
> per request (revocation is immediate). New caps `InstanceCreateGroup` / `Group{Read,Write,Admin}`
> (no `app_id` ⇒ bound API keys can't manage groups). Surfaced via `groups_api`, `pic groups …`,
> and a dashboard group tree + detail/members. `structure_version` is bumped but not yet a consumer
> beyond the reparent path (CLI structural-drift detection is Phase 5).
>
> Deferred to Phase 3+ (unchanged): group-*owned* scripts/vars/secret-refs, the materialized
> effective-view resolver, masked group secrets + the group-secret AAD scheme, module/import
> resolution under inheritance, `config --effective --explain`, and personal-namespace root groups
> (the backfill seeds a single instance root). The §10 **inherited-membership revocation-lag** risk
> does not bite in Phase 2 because resolution is live (no inherited-role cache); it returns only
> when Phase 3's materialized views land.
3. **Group-inherited config** (vars, secret-refs, env-scoped). The net-new `vars`/`secret-refs` 3. **Group-inherited config** (vars, secret-refs, env-scoped). The net-new `vars`/`secret-refs`
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard