feat(groups): tree repos, hierarchy-aware RBAC, admin API
Server-side foundation for Phase-2 groups (no group-owned resources yet):
Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
folding the highest effective role across the membership chain.
Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
coarse instance-wide structural advisory lock; slug frozen; bumps
structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.
Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
direct membership / none, so the ~18 existing test stubs are untouched);
the Postgres impl resolves each via one depth-bounded recursive CTE that
MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
on any ancestor is implicitly app_admin beneath it. New Capability
variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
no app_id (bound API keys can't manage groups). 8 new unit tests.
Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
responses carry group_id; my_role now reflects the effective role.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,17 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::authz::{AuthzError, AuthzRepo};
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
|
|
||||||
|
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||||
|
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||||
|
/// both effective-role queries.
|
||||||
|
const ROLE_RANK_CTE: &str =
|
||||||
|
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AppMembersRepositoryError {
|
pub enum AppMembersRepositoryError {
|
||||||
#[error("database error: {0}")]
|
#[error("database error: {0}")]
|
||||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
impl AuthzRepo for PostgresAppMembersRepository {
|
impl AuthzRepo for PostgresAppMembersRepository {
|
||||||
async fn membership(
|
async fn membership(
|
||||||
&self,
|
&self,
|
||||||
user_id: AdminUserId,
|
user_id: UserId,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AuthzError> {
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
self.find(user_id, app_id)
|
self.find(user_id, app_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||||
|
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||||
|
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||||
|
/// every ancestor `group_members` row by authority rank. A single
|
||||||
|
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||||
|
"WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.parent_id, anc.depth + 1
|
||||||
|
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
||||||
|
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
||||||
|
),
|
||||||
|
{ROLE_RANK_CTE}
|
||||||
|
SELECT eff.role
|
||||||
|
FROM (
|
||||||
|
SELECT am.role FROM app_members am
|
||||||
|
WHERE am.user_id = $1 AND am.app_id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT gm.role FROM group_members gm
|
||||||
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||||
|
WHERE gm.user_id = $1
|
||||||
|
) eff
|
||||||
|
JOIN role_rank rr ON rr.role = eff.role
|
||||||
|
ORDER BY rr.rank DESC
|
||||||
|
LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||||
|
"invalid role {role:?} in members table"
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on a *group* node — walks the group's own
|
||||||
|
/// ancestor chain over `group_members`. Gates group management.
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||||
|
"WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.id, g.parent_id, anc.depth + 1
|
||||||
|
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
||||||
|
WHERE anc.depth < 64
|
||||||
|
),
|
||||||
|
{ROLE_RANK_CTE}
|
||||||
|
SELECT gm.role
|
||||||
|
FROM group_members gm
|
||||||
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||||
|
JOIN role_rank rr ON rr.role = gm.role
|
||||||
|
WHERE gm.user_id = $1
|
||||||
|
ORDER BY rr.rank DESC
|
||||||
|
LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||||
|
"invalid role {role:?} in group_members table"
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! that writes the history row in the same transaction.
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AdminUserId, App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
|||||||
/// Only apps the user has an `app_members` row for. Drives the
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Apps whose parent is `group_id`. Drives the group detail view.
|
||||||
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug_or_history(
|
async fn get_by_slug_or_history(
|
||||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError>;
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
/// Create that also consumes a matching `app_slug_history` row, if
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
/// any. Used after the operator has confirmed they want to break old
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError>;
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
|
|||||||
impl AppRepository for PostgresAppRepository {
|
impl AppRepository for PostgresAppRepository {
|
||||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, AppRow>(
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps ORDER BY name",
|
FROM apps ORDER BY name",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, AppRow>(
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||||
FROM apps a \
|
FROM apps a \
|
||||||
JOIN app_members m ON m.app_id = a.id \
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
WHERE m.user_id = $1 \
|
WHERE m.user_id = $1 \
|
||||||
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
|
FROM apps WHERE group_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE id = $1",
|
FROM apps WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE slug = $1",
|
FROM apps WHERE slug = $1",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||||
FROM app_slug_history h \
|
FROM app_slug_history h \
|
||||||
JOIN apps a ON a.id = h.current_app_id \
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
WHERE h.slug = $1",
|
WHERE h.slug = $1",
|
||||||
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, AppRow>(
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
"INSERT INTO apps (slug, name, description) \
|
"INSERT INTO apps (slug, name, description, group_id) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"INSERT INTO apps (slug, name, description) \
|
"INSERT INTO apps (slug, name, description, group_id) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await;
|
.await;
|
||||||
let row = match row {
|
let row = match row {
|
||||||
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(name)
|
.bind(name)
|
||||||
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
if current_slug == new_slug {
|
if current_slug == new_slug {
|
||||||
// No-op rename; just return the row.
|
// No-op rename; just return the row.
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE id = $1",
|
FROM apps WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(new_slug)
|
.bind(new_slug)
|
||||||
@@ -432,6 +451,7 @@ struct AppRow {
|
|||||||
slug: String,
|
slug: String,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
group_id: uuid::Uuid,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
|
|||||||
slug: r.slug,
|
slug: r.slug,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
group_id: r.group_id.into(),
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
|||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ pub struct AppsState {
|
|||||||
pub domain_table: Arc<AppDomainTable>,
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
/// Capability gate — Phase 3.5.
|
/// Capability gate — Phase 3.5.
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
/// Group tree — resolves an app's parent group at create time
|
||||||
|
/// (defaults to the instance root).
|
||||||
|
pub groups: Arc<dyn GroupRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apps_router(state: AppsState) -> Router {
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Parent group (slug or id). Defaults to the instance root group when
|
||||||
|
/// omitted — every app has a parent from day one (§9).
|
||||||
|
#[serde(default)]
|
||||||
|
pub group: Option<String>,
|
||||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
/// the requested slug (breaking old redirects).
|
/// the requested slug (breaking old redirects).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -176,23 +184,46 @@ async fn create_app(
|
|||||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
validate_slug(&input.slug)?;
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||||
|
// instance root by default. Placing an app under a specific group
|
||||||
|
// additionally requires group-write there.
|
||||||
|
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||||
|
if input.group.is_some() {
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupWrite(parent.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Historical-slug check before insert: if the slug is in history
|
// Historical-slug check before insert: if the slug is in history
|
||||||
// and the caller hasn't asked to force takeover, surface a clean
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
// 409 so the dashboard can present a "this will break old links"
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
// confirmation.
|
// confirmation.
|
||||||
if !input.force_takeover {
|
if !input.force_takeover {
|
||||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
return Err(AppsApiError::SlugInHistory(current));
|
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let created = if input.force_takeover {
|
let created = if input.force_takeover {
|
||||||
s.apps
|
s.apps
|
||||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
.create_with_takeover(
|
||||||
|
&input.slug,
|
||||||
|
&input.name,
|
||||||
|
input.description.as_deref(),
|
||||||
|
parent.id,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
s.apps
|
s.apps
|
||||||
.create(&input.slug, &input.name, input.description.as_deref())
|
.create(
|
||||||
|
&input.slug,
|
||||||
|
&input.name,
|
||||||
|
input.description.as_deref(),
|
||||||
|
parent.id,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
@@ -235,7 +266,31 @@ async fn compute_my_role(
|
|||||||
) -> Result<Option<AppRole>, AppsApiError> {
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
match principal.instance_role {
|
match principal.instance_role {
|
||||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
// Effective role: folds in inherited group memberships so the
|
||||||
|
// dashboard badge reflects what the caller can actually do.
|
||||||
|
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||||
|
/// defaulting to the instance root group when `None`.
|
||||||
|
async fn resolve_group(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: Option<&str>,
|
||||||
|
) -> Result<picloud_shared::Group, AppsApiError> {
|
||||||
|
match ident {
|
||||||
|
None => groups
|
||||||
|
.get_by_slug(ROOT_GROUP_SLUG)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
|
||||||
|
Some(ident) => {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
groups.get_by_id(uuid.into()).await?
|
||||||
|
} else {
|
||||||
|
groups.get_by_slug(ident).await?
|
||||||
|
};
|
||||||
|
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +334,7 @@ async fn patch_app(
|
|||||||
Ok(app) => app,
|
Ok(app) => app,
|
||||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
return Err(AppsApiError::SlugInHistory(current));
|
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||||
}
|
}
|
||||||
return Err(AppsApiError::Conflict(msg));
|
return Err(AppsApiError::Conflict(msg));
|
||||||
}
|
}
|
||||||
@@ -521,14 +576,19 @@ pub enum AppsApiError {
|
|||||||
#[error("app not found: {0}")]
|
#[error("app not found: {0}")]
|
||||||
AppNotFound(String),
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("group not found: {0}")]
|
||||||
|
GroupNotFound(String),
|
||||||
|
|
||||||
#[error("domain not found: {0}")]
|
#[error("domain not found: {0}")]
|
||||||
DomainNotFound(Uuid),
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
#[error("invalid slug: {0}")]
|
#[error("invalid slug: {0}")]
|
||||||
InvalidSlug(String),
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||||
|
// every handler returning `Result<_, AppsApiError>`.
|
||||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
SlugInHistory(App),
|
SlugInHistory(Box<App>),
|
||||||
|
|
||||||
#[error("app still contains {0} script(s); delete or move them first")]
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
HasScripts(i64),
|
HasScripts(i64),
|
||||||
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
|
||||||
|
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
|
||||||
|
use crate::group_repo::GroupRepositoryError as G;
|
||||||
|
match e {
|
||||||
|
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
|
||||||
|
G::Conflict(msg) => Self::Conflict(msg),
|
||||||
|
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
Self::AppNotFound(_)
|
Self::AppNotFound(_)
|
||||||
|
| Self::GroupNotFound(_)
|
||||||
| Self::DomainNotFound(_)
|
| Self::DomainNotFound(_)
|
||||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
//! external user-facing label.
|
//! external user-facing label.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
|
||||||
|
|
||||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||||
/// the `AppId` of the resource the action targets — handlers compute
|
/// the `AppId` of the resource the action targets — handlers compute
|
||||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
|||||||
pub enum Capability {
|
pub enum Capability {
|
||||||
/// Create a new app. Owner / admin only.
|
/// Create a new app. Owner / admin only.
|
||||||
InstanceCreateApp,
|
InstanceCreateApp,
|
||||||
|
/// Create a new group (root-level). Owner / admin only — a Member
|
||||||
|
/// creates subgroups under a group they group-admin (gated by
|
||||||
|
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||||
|
InstanceCreateGroup,
|
||||||
|
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||||
|
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||||
|
GroupRead(GroupId),
|
||||||
|
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||||
|
/// group.
|
||||||
|
GroupWrite(GroupId),
|
||||||
|
/// Group settings: delete, reparent, manage group members. group_admin
|
||||||
|
/// on the group (inherited from any ancestor).
|
||||||
|
GroupAdmin(GroupId),
|
||||||
/// Create / update / delete admin_users rows (other than self
|
/// Create / update / delete admin_users rows (other than self
|
||||||
/// password change, which is a separate flow). Owner / admin.
|
/// password change, which is a separate flow). Owner / admin.
|
||||||
InstanceManageUsers,
|
InstanceManageUsers,
|
||||||
@@ -154,9 +167,16 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn app_id(self) -> Option<AppId> {
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
Self::InstanceCreateApp
|
||||||
None
|
| Self::InstanceManageUsers
|
||||||
}
|
| Self::InstanceManageSettings
|
||||||
|
| Self::InstanceCreateGroup
|
||||||
|
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||||
|
// None here so a bound API key (which can only target its one
|
||||||
|
// app) is denied group management at the binding layer.
|
||||||
|
| Self::GroupRead(_)
|
||||||
|
| Self::GroupWrite(_)
|
||||||
|
| Self::GroupAdmin(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -193,15 +213,17 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn required_scope(self) -> Scope {
|
pub const fn required_scope(self) -> Scope {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
Self::InstanceCreateApp
|
||||||
Scope::InstanceAdmin
|
| Self::InstanceManageUsers
|
||||||
}
|
| Self::InstanceManageSettings
|
||||||
|
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||||
Self::AppRead(_)
|
Self::AppRead(_)
|
||||||
| Self::AppKvRead(_)
|
| Self::AppKvRead(_)
|
||||||
| Self::AppDocsRead(_)
|
| Self::AppDocsRead(_)
|
||||||
| Self::AppFilesRead(_)
|
| Self::AppFilesRead(_)
|
||||||
| Self::AppSecretsRead(_)
|
| Self::AppSecretsRead(_)
|
||||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
| Self::AppUsersRead(_)
|
||||||
|
| Self::GroupRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -219,7 +241,9 @@ impl Capability {
|
|||||||
Self::AppAdmin(_)
|
Self::AppAdmin(_)
|
||||||
| Self::AppManageTriggers(_)
|
| Self::AppManageTriggers(_)
|
||||||
| Self::AppDeadLetterManage(_)
|
| Self::AppDeadLetterManage(_)
|
||||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
| Self::AppTopicManage(_)
|
||||||
|
| Self::GroupWrite(_)
|
||||||
|
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +254,41 @@ impl Capability {
|
|||||||
/// means unit tests can stub it.
|
/// means unit tests can stub it.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AuthzRepo: Send + Sync {
|
pub trait AuthzRepo: Send + Sync {
|
||||||
|
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||||
|
/// used by member-management surfaces and as the fallback below.
|
||||||
async fn membership(
|
async fn membership(
|
||||||
&self,
|
&self,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AuthzError>;
|
) -> Result<Option<AppRole>, AuthzError>;
|
||||||
|
|
||||||
|
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
||||||
|
/// the app's own `app_members` row folded with every `group_members`
|
||||||
|
/// row on any ancestor group, max-by-authority. This is what `can()`
|
||||||
|
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
||||||
|
/// on the app.
|
||||||
|
///
|
||||||
|
/// Default = direct membership only (no inheritance), so the many test
|
||||||
|
/// stubs that model no group tree keep their existing behavior; the
|
||||||
|
/// Postgres repo overrides this with an ancestor-walking CTE.
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
self.membership(user_id, app_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on a *group* node — the group's own
|
||||||
|
/// ancestor walk over `group_members`. Gates the group-management
|
||||||
|
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
_user_id: UserId,
|
||||||
|
_group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repo errors surface here so handlers can map them to 500 without
|
/// Repo errors surface here so handlers can map them to 500 without
|
||||||
@@ -353,7 +407,20 @@ async fn role_grants(
|
|||||||
match principal.instance_role {
|
match principal.instance_role {
|
||||||
InstanceRole::Owner => Ok(true),
|
InstanceRole::Owner => Ok(true),
|
||||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
InstanceRole::Member => match cap {
|
||||||
|
// Group-management caps resolve against the group ancestor
|
||||||
|
// walk (a group_admin on an ancestor is implicitly admin of
|
||||||
|
// the descendant group). Routed before member_grants because
|
||||||
|
// group caps carry no app_id.
|
||||||
|
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
||||||
|
group_member_grants(repo, principal.user_id, cap, g).await
|
||||||
|
}
|
||||||
|
// Creating a root-level group is an instance act — members
|
||||||
|
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||||
|
// the handler, which routes through the arm above.)
|
||||||
|
Capability::InstanceCreateGroup => Ok(false),
|
||||||
|
_ => member_grants(repo, principal.user_id, cap).await,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +444,41 @@ async fn member_grants(
|
|||||||
let Some(app_id) = cap.app_id() else {
|
let Some(app_id) = cap.app_id() else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
// Effective (inherited) role: the app's own membership folded with any
|
||||||
|
// ancestor group membership. A group_admin on an ancestor group is
|
||||||
|
// implicitly app_admin here.
|
||||||
|
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
Ok(role_satisfies(role, cap))
|
Ok(role_satisfies(role, cap))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Member-path resolution for the group-management capabilities. Resolves
|
||||||
|
/// the caller's effective role on the group (ancestor walk over
|
||||||
|
/// `group_members`) and checks it covers the requested group action.
|
||||||
|
async fn group_member_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
user_id: UserId,
|
||||||
|
cap: Capability,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(group_role_satisfies(role, cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the effective group `AppRole` cover the group capability?
|
||||||
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||||
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
|
match cap {
|
||||||
|
Capability::GroupRead(_) => true, // any role can read
|
||||||
|
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
||||||
|
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||||
@@ -471,16 +567,61 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||||
|
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||||
|
/// and group memberships, so the hierarchy-aware resolution can be
|
||||||
|
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct InMemoryAuthzRepo {
|
struct InMemoryAuthzRepo {
|
||||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||||
|
app_group: Mutex<HashMap<AppId, GroupId>>,
|
||||||
|
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
||||||
|
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryAuthzRepo {
|
impl InMemoryAuthzRepo {
|
||||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||||
self.memberships.lock().await.insert((user, app), role);
|
self.memberships.lock().await.insert((user, app), role);
|
||||||
}
|
}
|
||||||
|
/// Register a group node and its parent (`None` = root).
|
||||||
|
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
||||||
|
self.group_parent.lock().await.insert(group, parent);
|
||||||
|
}
|
||||||
|
/// Place an app under a group.
|
||||||
|
async fn put_app(&self, app: AppId, group: GroupId) {
|
||||||
|
self.app_group.lock().await.insert(app, group);
|
||||||
|
}
|
||||||
|
/// Grant a group-level role.
|
||||||
|
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
||||||
|
self.group_memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((user, group), role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold every ancestor group membership starting at `group`,
|
||||||
|
/// max-by-authority, into `acc`.
|
||||||
|
async fn fold_group_chain(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
mut group: Option<GroupId>,
|
||||||
|
mut acc: Option<AppRole>,
|
||||||
|
) -> Option<AppRole> {
|
||||||
|
let memberships = self.group_memberships.lock().await;
|
||||||
|
let parents = self.group_parent.lock().await;
|
||||||
|
let mut hops = 0u32;
|
||||||
|
while let Some(g) = group {
|
||||||
|
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
||||||
|
acc = Some(acc.map_or(r, |a| a.max(r)));
|
||||||
|
}
|
||||||
|
hops += 1;
|
||||||
|
if hops > 64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
group = parents.get(&g).copied().flatten();
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -497,6 +638,29 @@ mod tests {
|
|||||||
.get(&(user_id, app_id))
|
.get(&(user_id, app_id))
|
||||||
.copied())
|
.copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let direct = self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied();
|
||||||
|
let start = self.app_group.lock().await.get(&app_id).copied();
|
||||||
|
Ok(self.fold_group_chain(user_id, start, direct).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn principal(role: InstanceRole) -> Principal {
|
fn principal(role: InstanceRole) -> Principal {
|
||||||
@@ -857,12 +1021,183 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
repo.put_app(app, acme).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
// No app_members row — authority comes purely from the group.
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
for cap in [
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||||
|
"inherited group_admin denied {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
repo.put_app(app, acme).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
// Direct viewer on the app, app_admin via the ancestor group:
|
||||||
|
// the higher (app_admin) wins.
|
||||||
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
repo.put_app(app, team).await;
|
||||||
|
|
||||||
|
// Editor two levels up flows down to the app as editor.
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"editor must not get app_admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_membership_grants_no_instance_capabilities() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceCreateGroup,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"group_admin must not grant instance cap {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
// group_admin at root ⇒ admin of the descendant group.
|
||||||
|
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
// An unrelated member gets nothing.
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let g = GroupId::new();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateGroup,
|
||||||
|
Capability::GroupRead(g),
|
||||||
|
Capability::GroupWrite(g),
|
||||||
|
Capability::GroupAdmin(g),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||||
|
"admin denied group cap {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_manage_groups() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let g = GroupId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::AppAdmin]),
|
||||||
|
app_binding: Some(AppId::new()),
|
||||||
|
};
|
||||||
|
// Group caps carry no app_id, so a bound key is denied at the
|
||||||
|
// binding layer regardless of role/scope.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_role_max_is_authority_ordered() {
|
||||||
|
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||||
|
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||||
|
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_app_id_extraction() {
|
fn capability_app_id_extraction() {
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||||
|
// Group caps are not app-scoped.
|
||||||
|
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||||
|
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
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_service;
|
||||||
pub mod files_sweep;
|
pub mod files_sweep;
|
||||||
pub mod gc;
|
pub mod gc;
|
||||||
|
pub mod group_members_repo;
|
||||||
|
pub mod group_repo;
|
||||||
|
pub mod groups_api;
|
||||||
pub mod http_service;
|
pub mod http_service;
|
||||||
pub mod invoke_service;
|
pub mod invoke_service;
|
||||||
pub mod kv_api;
|
pub mod kv_api;
|
||||||
@@ -165,6 +168,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
|||||||
pub use files_service::FilesServiceImpl;
|
pub use files_service::FilesServiceImpl;
|
||||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||||
|
pub use group_members_repo::{
|
||||||
|
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||||
|
PostgresGroupMembersRepository,
|
||||||
|
};
|
||||||
|
pub use group_repo::{
|
||||||
|
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||||
|
ROOT_GROUP_SLUG,
|
||||||
|
};
|
||||||
|
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ mod tests {
|
|||||||
slug: self.slug.clone(),
|
slug: self.slug.clone(),
|
||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
description: None,
|
description: None,
|
||||||
|
group_id: picloud_shared::GroupId::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -396,6 +397,12 @@ mod tests {
|
|||||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn list_for_group(
|
||||||
|
&self,
|
||||||
|
_: picloud_shared::GroupId,
|
||||||
|
) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
if id != self.id {
|
if id != self.id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -436,6 +443,7 @@ mod tests {
|
|||||||
_: &str,
|
_: &str,
|
||||||
_: &str,
|
_: &str,
|
||||||
_: Option<&str>,
|
_: Option<&str>,
|
||||||
|
_: picloud_shared::GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -444,6 +452,7 @@ mod tests {
|
|||||||
_: &str,
|
_: &str,
|
||||||
_: &str,
|
_: &str,
|
||||||
_: Option<&str>,
|
_: Option<&str>,
|
||||||
|
_: picloud_shared::GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1209,6 +1209,7 @@ mod tests {
|
|||||||
slug: "test".into(),
|
slug: "test".into(),
|
||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
description: None,
|
description: None,
|
||||||
|
group_id: picloud_shared::GroupId::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
@@ -1226,6 +1227,7 @@ mod tests {
|
|||||||
_slug: &str,
|
_slug: &str,
|
||||||
_name: &str,
|
_name: &str,
|
||||||
_description: Option<&str>,
|
_description: Option<&str>,
|
||||||
|
_group_id: picloud_shared::GroupId,
|
||||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1234,6 +1236,7 @@ mod tests {
|
|||||||
_slug: &str,
|
_slug: &str,
|
||||||
_name: &str,
|
_name: &str,
|
||||||
_description: Option<&str>,
|
_description: Option<&str>,
|
||||||
|
_group_id: picloud_shared::GroupId,
|
||||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1252,6 +1255,12 @@ mod tests {
|
|||||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn list_for_group(
|
||||||
|
&self,
|
||||||
|
_group_id: picloud_shared::GroupId,
|
||||||
|
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn get_by_id(
|
async fn get_by_id(
|
||||||
&self,
|
&self,
|
||||||
id: AppId,
|
id: AppId,
|
||||||
@@ -1725,6 +1734,7 @@ mod tests {
|
|||||||
slug: "a".into(),
|
slug: "a".into(),
|
||||||
name: "a".into(),
|
name: "a".into(),
|
||||||
description: None,
|
description: None,
|
||||||
|
group_id: picloud_shared::GroupId::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
@@ -1736,6 +1746,7 @@ mod tests {
|
|||||||
slug: "b".into(),
|
slug: "b".into(),
|
||||||
name: "b".into(),
|
name: "b".into(),
|
||||||
description: None,
|
description: None,
|
||||||
|
group_id: picloud_shared::GroupId::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,26 +12,28 @@ use picloud_executor_core::{Engine, Limits};
|
|||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
||||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||||
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
|
||||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
|
||||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
||||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
||||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
||||||
|
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
|
||||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
|
||||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||||
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
|
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||||
|
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
@@ -109,6 +111,10 @@ pub async fn build_app(
|
|||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let groups_repo: Arc<dyn GroupRepository> =
|
||||||
|
Arc::new(PostgresGroupRepository::new(pool.clone()));
|
||||||
|
let group_members_repo: Arc<dyn GroupMembersRepository> =
|
||||||
|
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
|
||||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
// The Postgres app_members repo implements both `AppMembersRepository`
|
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||||
@@ -513,6 +519,7 @@ pub async fn build_app(
|
|||||||
routes: route_repo,
|
routes: route_repo,
|
||||||
domain_table: app_domain_table.clone(),
|
domain_table: app_domain_table.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
|
groups: groups_repo.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
|
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
|
||||||
@@ -552,6 +559,13 @@ pub async fn build_app(
|
|||||||
members,
|
members,
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
|
let groups_state = GroupsState {
|
||||||
|
groups: groups_repo.clone(),
|
||||||
|
group_members: group_members_repo.clone(),
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
users: auth.users.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||||
apps: apps_state.apps.clone(),
|
apps: apps_state.apps.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
@@ -574,6 +588,7 @@ pub async fn build_app(
|
|||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
.merge(app_members_router(app_members_state))
|
.merge(app_members_router(app_members_state))
|
||||||
|
.merge(groups_router(groups_state))
|
||||||
.merge(picloud_manager_core::app_users_router(
|
.merge(picloud_manager_core::app_users_router(
|
||||||
app_users_admin_state,
|
app_users_admin_state,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::AppId;
|
use crate::{AppId, GroupId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -20,6 +20,9 @@ pub struct App {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Parent group in the org tree (Phase 2). Every app has a parent
|
||||||
|
/// from day one (§9 backfill seeds the instance root group).
|
||||||
|
pub group_id: GroupId,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,30 @@ impl AppRole {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authority rank: higher = more authority. Used to fold the highest
|
||||||
|
/// effective role across an app's own membership and every ancestor
|
||||||
|
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
|
||||||
|
/// than via a derived `Ord` because the variant declaration order
|
||||||
|
/// (`AppAdmin` first) is the reverse of authority order.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn precedence(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::AppAdmin => 3,
|
||||||
|
Self::Editor => 2,
|
||||||
|
Self::Viewer => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max(self, other: Self) -> Self {
|
||||||
|
if self.precedence() >= other.precedence() {
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
other
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||||
|
|||||||
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!(AppUserId);
|
||||||
id_type!(InvitationId);
|
id_type!(InvitationId);
|
||||||
id_type!(QueueMessageId);
|
id_type!(QueueMessageId);
|
||||||
|
id_type!(GroupId);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod events;
|
|||||||
pub mod exec_summary;
|
pub mod exec_summary;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod group;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
@@ -62,10 +63,11 @@ pub use files::{
|
|||||||
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
||||||
SAFE_RENDER_FALLBACK,
|
SAFE_RENDER_FALLBACK,
|
||||||
};
|
};
|
||||||
|
pub use group::Group;
|
||||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
|
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||||
ScriptId, TriggerId,
|
RequestId, ScriptId, TriggerId,
|
||||||
};
|
};
|
||||||
pub use inbox::{
|
pub use inbox::{
|
||||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||||
|
|||||||
Reference in New Issue
Block a user