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:
@@ -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() }))
|
||||
|
||||
Reference in New Issue
Block a user