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:
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal 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);
|
||||
@@ -8,11 +8,17 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
|
||||
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||
/// both effective-role queries.
|
||||
const ROLE_RANK_CTE: &str =
|
||||
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
||||
impl AuthzRepo for PostgresAppMembersRepository {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.find(user_id, app_id)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||
}
|
||||
|
||||
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||
/// every ancestor `group_members` row by authority rank. A single
|
||||
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<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)]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, App, AppId};
|
||||
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
||||
/// Only apps the user has an `app_members` row for. Drives the
|
||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<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_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM apps a \
|
||||
JOIN app_members m ON m.app_id = a.id \
|
||||
WHERE m.user_id = $1 \
|
||||
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<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> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
@@ -432,6 +451,7 @@ struct AppRow {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
group_id: uuid::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
group_id: r.group_id.into(),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
@@ -44,6 +45,9 @@ pub struct AppsState {
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
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 {
|
||||
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: 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
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
@@ -176,23 +184,46 @@ async fn create_app(
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||
// instance root by default. Placing an app under a specific group
|
||||
// additionally requires group-write there.
|
||||
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||
if input.group.is_some() {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(parent.id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.create_with_takeover(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
@@ -235,7 +266,31 @@ async fn compute_my_role(
|
||||
) -> Result<Option<AppRole>, AppsApiError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||
// Effective role: folds in inherited group memberships so the
|
||||
// dashboard badge reflects what the caller can actually do.
|
||||
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||
/// defaulting to the instance root group when `None`.
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: Option<&str>,
|
||||
) -> Result<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,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
@@ -521,14 +576,19 @@ pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||
// every handler returning `Result<_, AppsApiError>`.
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
SlugInHistory(Box<App>),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
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 {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::GroupNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! external user-facing label.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
|
||||
|
||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||
/// the `AppId` of the resource the action targets — handlers compute
|
||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub enum Capability {
|
||||
/// Create a new app. Owner / admin only.
|
||||
InstanceCreateApp,
|
||||
/// Create a new group (root-level). Owner / admin only — a Member
|
||||
/// creates subgroups under a group they group-admin (gated by
|
||||
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||
InstanceCreateGroup,
|
||||
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||
GroupRead(GroupId),
|
||||
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||
/// group.
|
||||
GroupWrite(GroupId),
|
||||
/// Group settings: delete, reparent, manage group members. group_admin
|
||||
/// on the group (inherited from any ancestor).
|
||||
GroupAdmin(GroupId),
|
||||
/// Create / update / delete admin_users rows (other than self
|
||||
/// password change, which is a separate flow). Owner / admin.
|
||||
InstanceManageUsers,
|
||||
@@ -154,9 +167,16 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn app_id(self) -> Option<AppId> {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
None
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup
|
||||
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||
// None here so a bound API key (which can only target its one
|
||||
// app) is denied group management at the binding layer.
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -193,15 +213,17 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn required_scope(self) -> Scope {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_)
|
||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::GroupRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -219,7 +241,9 @@ impl Capability {
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
| Self::AppTopicManage(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +254,41 @@ impl Capability {
|
||||
/// means unit tests can stub it.
|
||||
#[async_trait]
|
||||
pub trait AuthzRepo: Send + Sync {
|
||||
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||
/// used by member-management surfaces and as the fallback below.
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<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
|
||||
@@ -353,7 +407,20 @@ async fn role_grants(
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(true),
|
||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||
InstanceRole::Member => match cap {
|
||||
// Group-management caps resolve against the group ancestor
|
||||
// walk (a group_admin on an ancestor is implicitly admin of
|
||||
// the descendant group). Routed before member_grants because
|
||||
// group caps carry no app_id.
|
||||
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||
// the handler, which routes through the arm above.)
|
||||
Capability::InstanceCreateGroup => Ok(false),
|
||||
_ => member_grants(repo, principal.user_id, cap).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,12 +444,41 @@ async fn member_grants(
|
||||
let Some(app_id) = cap.app_id() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||
// Effective (inherited) role: the app's own membership folded with any
|
||||
// ancestor group membership. A group_admin on an ancestor group is
|
||||
// implicitly app_admin here.
|
||||
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Member-path resolution for the group-management capabilities. Resolves
|
||||
/// the caller's effective role on the group (ancestor walk over
|
||||
/// `group_members`) and checks it covers the requested group action.
|
||||
async fn group_member_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
user_id: UserId,
|
||||
cap: Capability,
|
||||
group_id: GroupId,
|
||||
) -> Result<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;
|
||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||
@@ -471,16 +567,61 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||
/// and group memberships, so the hierarchy-aware resolution can be
|
||||
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||
#[derive(Default)]
|
||||
struct InMemoryAuthzRepo {
|
||||
memberships: Mutex<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 {
|
||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||
self.memberships.lock().await.insert((user, app), role);
|
||||
}
|
||||
/// Register a group node and its parent (`None` = root).
|
||||
async fn add_group(&self, group: GroupId, parent: Option<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]
|
||||
@@ -497,6 +638,29 @@ mod tests {
|
||||
.get(&(user_id, app_id))
|
||||
.copied())
|
||||
}
|
||||
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<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 {
|
||||
@@ -857,12 +1021,183 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// No app_members row — authority comes purely from the group.
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"inherited group_admin denied {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// Direct viewer on the app, app_admin via the ancestor group:
|
||||
// the higher (app_admin) wins.
|
||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
repo.put_app(app, team).await;
|
||||
|
||||
// Editor two levels up flows down to the app as editor.
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"editor must not get app_admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_membership_grants_no_instance_capabilities() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::InstanceManageUsers,
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"group_admin must not grant instance cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||
|
||||
// group_admin at root ⇒ admin of the descendant group.
|
||||
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets nothing.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
for cap in [
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::GroupRead(g),
|
||||
Capability::GroupWrite(g),
|
||||
Capability::GroupAdmin(g),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"admin denied group cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_manage_groups() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::AppAdmin]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
// Group caps carry no app_id, so a bound key is denied at the
|
||||
// binding layer regardless of role/scope.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_role_max_is_authority_ordered() {
|
||||
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_app_id_extraction() {
|
||||
let app = AppId::new();
|
||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||
// Group caps are not app-scoped.
|
||||
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
205
crates/manager-core/src/group_members_repo.rs
Normal file
205
crates/manager-core/src/group_members_repo.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
367
crates/manager-core/src/group_repo.rs
Normal file
367
crates/manager-core/src/group_repo.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/manager-core/src/groups_api.rs
Normal file
577
crates/manager-core/src/groups_api.rs
Normal 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 1–63 characters"));
|
||||
}
|
||||
let mut chars = slug.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(invalid("must start with a lowercase letter or digit"));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"may contain only lowercase letters, digits, and hyphens",
|
||||
));
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(invalid("is a reserved word"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
|
||||
if !user.is_active {
|
||||
return Err(GroupsApiError::TargetInactive {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
}
|
||||
if user.instance_role != InstanceRole::Member {
|
||||
return Err(GroupsApiError::TargetNotMember {
|
||||
username: user.username.clone(),
|
||||
instance_role: user.instance_role,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
|
||||
GroupMemberDto {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
instance_role: user.instance_role,
|
||||
is_active: user.is_active,
|
||||
role: membership.role,
|
||||
created_at: membership.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupsApiError {
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(AdminUserId),
|
||||
#[error("{username} is already a member of this group")]
|
||||
AlreadyMember { username: String },
|
||||
#[error("membership not found")]
|
||||
MembershipNotFound,
|
||||
#[error("{username} is deactivated")]
|
||||
TargetInactive { username: String },
|
||||
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
|
||||
TargetNotMember {
|
||||
username: String,
|
||||
instance_role: InstanceRole,
|
||||
},
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("group repository error: {0}")]
|
||||
Repo(#[from] GroupRepositoryError),
|
||||
#[error("member repository error: {0}")]
|
||||
MembersRepo(#[from] GroupMembersRepositoryError),
|
||||
#[error("user repository error: {0}")]
|
||||
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
#[error("app repository error: {0}")]
|
||||
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<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()
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
pub mod kv_api;
|
||||
@@ -165,6 +168,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -396,6 +397,12 @@ mod tests {
|
||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
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> {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
@@ -436,6 +443,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -444,6 +452,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1209,6 +1209,7 @@ mod tests {
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1226,6 +1227,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1234,6 +1236,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1252,6 +1255,12 @@ mod tests {
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: AppId,
|
||||
@@ -1725,6 +1734,7 @@ mod tests {
|
||||
slug: "a".into(),
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1736,6 +1746,7 @@ mod tests {
|
||||
slug: "b".into(),
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,6 +150,144 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Groups (Phase 2) -------------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
|
||||
/// client-side from `parent_id`).
|
||||
pub async fn groups_list(&self) -> Result<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
|
||||
/// owning app (stricter than the edit endpoints, by design).
|
||||
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||
@@ -1049,6 +1188,31 @@ pub struct CreateAppBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for the instance root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for a root-level group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
|
||||
/// direct children.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -31,6 +31,7 @@ pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
group: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -39,6 +40,7 @@ pub async fn create(
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
group,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
// Emit the created object so `--output json` callers can capture the
|
||||
|
||||
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
257
crates/picloud-cli/src/cmds/groups.rs
Normal 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"))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
|
||||
@@ -51,6 +51,12 @@ enum Cmd {
|
||||
cmd: AppsCmd,
|
||||
},
|
||||
|
||||
/// Group (org-tree) management.
|
||||
Groups {
|
||||
#[command(subcommand)]
|
||||
cmd: GroupsCmd,
|
||||
},
|
||||
|
||||
/// Script management.
|
||||
Scripts {
|
||||
#[command(subcommand)]
|
||||
@@ -401,6 +407,9 @@ enum AppsCmd {
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
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.
|
||||
@@ -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)]
|
||||
enum DomainsCmd {
|
||||
/// List the app's domain claims.
|
||||
@@ -1176,8 +1254,18 @@ async fn main() -> ExitCode {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
group,
|
||||
},
|
||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await,
|
||||
} => {
|
||||
cmds::apps::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
group.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Show { ident },
|
||||
} => cmds::apps::show(&ident, mode).await,
|
||||
@@ -1201,6 +1289,79 @@ async fn main() -> ExitCode {
|
||||
cmd: DomainsCmd::Rm { app, domain_id },
|
||||
},
|
||||
} => cmds::apps_domains::rm(&app, &domain_id).await,
|
||||
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Tree,
|
||||
} => cmds::groups::tree(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Create {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
parent,
|
||||
},
|
||||
} => {
|
||||
cmds::groups::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
parent.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Show { ident },
|
||||
} => cmds::groups::show(&ident, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Rename {
|
||||
ident,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Reparent { ident, to },
|
||||
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Rm { ident, recursive },
|
||||
} => cmds::groups::rm(&ident, recursive).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Ls { group },
|
||||
},
|
||||
} => cmds::groups::members_ls(&group, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Add {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Set {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Rm { group, user_id },
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
|
||||
@@ -23,6 +23,7 @@ mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
|
||||
@@ -44,6 +44,35 @@ pub struct UserGuard {
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// Deletes a group on drop (best-effort). The group must be empty by then
|
||||
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
|
||||
/// child drops (deletes) first, leaving an empty node here.
|
||||
pub struct GroupGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl GroupGuard {
|
||||
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
slug: slug.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GroupGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
impl UserGuard {
|
||||
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||
Self {
|
||||
|
||||
179
crates/picloud-cli/tests/groups.rs
Normal file
179
crates/picloud-cli/tests/groups.rs
Normal 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"));
|
||||
}
|
||||
@@ -12,26 +12,28 @@ use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
||||
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
|
||||
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
|
||||
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
||||
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
||||
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
||||
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
|
||||
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
|
||||
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
|
||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -109,6 +111,10 @@ pub async fn build_app(
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::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 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> =
|
||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||
@@ -513,6 +519,7 @@ pub async fn build_app(
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table.clone(),
|
||||
authz: authz.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
};
|
||||
|
||||
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
|
||||
@@ -552,6 +559,13 @@ pub async fn build_app(
|
||||
members,
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let groups_state = GroupsState {
|
||||
groups: groups_repo.clone(),
|
||||
group_members: group_members_repo.clone(),
|
||||
apps: apps_state.apps.clone(),
|
||||
users: auth.users.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||
apps: apps_state.apps.clone(),
|
||||
authz: authz.clone(),
|
||||
@@ -574,6 +588,7 @@ pub async fn build_app(
|
||||
.merge(admins_router(admins_state))
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
app_users_admin_state,
|
||||
))
|
||||
|
||||
@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppId;
|
||||
use crate::{AppId, GroupId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct App {
|
||||
@@ -20,6 +20,9 @@ pub struct App {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<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 updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -98,6 +98,30 @@ impl AppRole {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authority rank: higher = more authority. Used to fold the highest
|
||||
/// effective role across an app's own membership and every ancestor
|
||||
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
|
||||
/// than via a derived `Ord` because the variant declaration order
|
||||
/// (`AppAdmin` first) is the reverse of authority order.
|
||||
#[must_use]
|
||||
pub const fn precedence(self) -> u8 {
|
||||
match self {
|
||||
Self::AppAdmin => 3,
|
||||
Self::Editor => 2,
|
||||
Self::Viewer => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
|
||||
#[must_use]
|
||||
pub fn max(self, other: Self) -> Self {
|
||||
if self.precedence() >= other.precedence() {
|
||||
self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||
|
||||
31
crates/shared/src/group.rs
Normal file
31
crates/shared/src/group.rs
Normal 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>,
|
||||
}
|
||||
@@ -57,3 +57,4 @@ id_type!(TriggerId);
|
||||
id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
id_type!(QueueMessageId);
|
||||
id_type!(GroupId);
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod events;
|
||||
pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -62,10 +63,11 @@ pub use files::{
|
||||
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
|
||||
ScriptId, TriggerId,
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
RequestId, ScriptId, TriggerId,
|
||||
};
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
|
||||
@@ -50,6 +50,47 @@ export interface App {
|
||||
|
||||
export type AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
structure_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GroupDetail extends Group {
|
||||
/** Root → … → this node breadcrumb. */
|
||||
path: Group[];
|
||||
subgroups: Group[];
|
||||
apps: App[];
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
instance_role: InstanceRole;
|
||||
is_active: boolean;
|
||||
role: AppRole;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** Parent group slug or id; omit (or null) for a root group. */
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchGroupInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||
|
||||
export interface AppDomain {
|
||||
@@ -89,6 +130,8 @@ export interface CreateAppInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
force_takeover?: boolean;
|
||||
/** Parent group slug or id; omit for the root group. */
|
||||
group?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchAppInput {
|
||||
@@ -778,6 +821,52 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
groups: {
|
||||
list: () => adminRequest<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: {
|
||||
listForApp: (idOrSlug: string) =>
|
||||
adminRequest<AppDomain[]>(
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/apps'}>Apps</a>
|
||||
<a href={base + '/groups'}>Groups</a>
|
||||
{#if user && user.instance_role !== 'member'}
|
||||
<a href={base + '/users'}>Users</a>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { canCreateApp } from '$lib/capabilities';
|
||||
import { currentUser } from '$lib/auth';
|
||||
@@ -36,6 +36,17 @@
|
||||
let createSlug = $state('');
|
||||
let createName = $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
|
||||
// the slug field. Clearing the slug input releases the lock so the
|
||||
// auto-derive resumes — matches the GitLab project-create UX.
|
||||
@@ -69,6 +80,7 @@
|
||||
listError = null;
|
||||
try {
|
||||
apps = await api.apps.list();
|
||||
void loadGroups();
|
||||
if (apps && apps.length > 0) {
|
||||
void loadDlCounts(apps);
|
||||
}
|
||||
@@ -84,6 +96,7 @@
|
||||
createSlug = '';
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createGroup = '';
|
||||
createError = null;
|
||||
createHistoricalConflict = null;
|
||||
slugTouched = false;
|
||||
@@ -99,7 +112,8 @@
|
||||
slug: createSlug.trim(),
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
force_takeover: forceTakeover || undefined
|
||||
force_takeover: forceTakeover || undefined,
|
||||
group: createGroup.trim() || undefined
|
||||
});
|
||||
showCreate = false;
|
||||
resetCreate();
|
||||
@@ -172,6 +186,15 @@
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</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}
|
||||
<div class="warning">
|
||||
<strong>Slug previously redirected.</strong>
|
||||
|
||||
366
dashboard/src/routes/groups/+page.svelte
Normal file
366
dashboard/src/routes/groups/+page.svelte
Normal 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>
|
||||
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal file
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal 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>
|
||||
@@ -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** +
|
||||
**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.
|
||||
|
||||
> **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`
|
||||
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
|
||||
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
||||
|
||||
Reference in New Issue
Block a user