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>
206 lines
6.4 KiB
Rust
206 lines
6.4 KiB
Rust
//! 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,
|
|
})
|
|
}
|
|
}
|