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:
MechaCat02
2026-06-24 19:59:00 +02:00
parent 2b27012f56
commit a432091191
16 changed files with 1826 additions and 53 deletions

View File

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