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