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