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>
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
//! CRUD over the `app_members` table — explicit per-(user, app) role
|
|
//! grants for `member` instance-role users. Owners and admins do NOT
|
|
//! appear here; their app authority is implicit (see authz.rs).
|
|
//!
|
|
//! Doubles as the production `AuthzRepo` implementation: the
|
|
//! membership lookup `can()` needs is the same single-row SELECT as
|
|
//! `find` here.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
|
use sqlx::PgPool;
|
|
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
|
|
/// SQL fragment ranking the three role literals by authority so a CTE can
|
|
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
|
/// both effective-role queries.
|
|
const ROLE_RANK_CTE: &str =
|
|
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppMembersRepositoryError {
|
|
#[error("database error: {0}")]
|
|
Db(#[from] sqlx::Error),
|
|
|
|
#[error("membership row not found: app={app_id}, user={user_id}")]
|
|
NotFound { app_id: AppId, user_id: AdminUserId },
|
|
|
|
#[error("invalid app_role stored in DB: {0}")]
|
|
InvalidRole(String),
|
|
}
|
|
|
|
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
|
|
/// so handlers can render the cross-reference without joining to apps
|
|
/// or admin_users themselves.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppMembershipRow {
|
|
pub app_id: AppId,
|
|
pub user_id: AdminUserId,
|
|
pub role: AppRole,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// `app_members` row joined with `admin_users` so the dashboard's
|
|
/// Members tab can render usernames / emails / status without an N+1
|
|
/// fetch per row. Drives `GET /apps/{id}/members`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppMembershipDetail {
|
|
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 AppMembersRepository: Send + Sync {
|
|
/// Single (user, app) lookup. Returns `None` for non-members and
|
|
/// for unrelated apps. This is the hot path for `authz::can`.
|
|
async fn find(
|
|
&self,
|
|
user_id: AdminUserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
|
|
|
|
/// Upsert a membership. Used both for first-time grants and role
|
|
/// promotions/demotions on an existing row.
|
|
async fn upsert(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
|
|
|
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
|
/// membership already exists. Lets the HTTP handler return 409
|
|
/// without a separate `find` round-trip (no TOCTOU between check
|
|
/// and insert).
|
|
async fn try_insert(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
|
|
|
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
|
/// membership row exists. Lets PATCH return 404 without a separate
|
|
/// `find` round-trip (no TOCTOU between check and update).
|
|
async fn update_role(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
|
|
|
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
|
/// the user wasn't a member, which is the desired post-condition.
|
|
async fn remove(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
) -> Result<(), AppMembersRepositoryError>;
|
|
|
|
/// Every membership the user holds. Drives the membership-filtered
|
|
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
|
|
/// `member` callers).
|
|
async fn list_for_user(
|
|
&self,
|
|
user_id: AdminUserId,
|
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
|
|
|
/// Every membership on a given app. Used by `GET
|
|
/// /admin/apps/{id}/members` once that surface lands; included now
|
|
/// so the trait is complete enough for tests.
|
|
async fn list_for_app(
|
|
&self,
|
|
app_id: AppId,
|
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
|
|
|
/// Like `list_for_app` but joined with `admin_users` so the
|
|
/// dashboard can render member rows in one round-trip. Ordered by
|
|
/// username for a stable list.
|
|
async fn list_for_app_enriched(
|
|
&self,
|
|
app_id: AppId,
|
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
|
}
|
|
|
|
pub struct PostgresAppMembersRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresAppMembersRepository {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AppMembersRepository for PostgresAppMembersRepository {
|
|
async fn find(
|
|
&self,
|
|
user_id: AdminUserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
|
|
let row: Option<(String,)> =
|
|
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
|
|
.bind(user_id.into_inner())
|
|
.bind(app_id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(|(role,)| {
|
|
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
async fn upsert(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
|
"INSERT INTO app_members (app_id, user_id, role) \
|
|
VALUES ($1, $2, $3) \
|
|
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
|
|
RETURNING app_id, user_id, role, created_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(user_id.into_inner())
|
|
.bind(role.as_str())
|
|
.fetch_one(&self.pool)
|
|
.await?;
|
|
row.try_into()
|
|
}
|
|
|
|
async fn remove(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
) -> Result<(), AppMembersRepositoryError> {
|
|
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
|
|
.bind(app_id.into_inner())
|
|
.bind(user_id.into_inner())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn try_insert(
|
|
&self,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
|
"INSERT INTO app_members (app_id, user_id, role) \
|
|
VALUES ($1, $2, $3) \
|
|
ON CONFLICT (app_id, user_id) DO NOTHING \
|
|
RETURNING app_id, user_id, role, created_at",
|
|
)
|
|
.bind(app_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,
|
|
app_id: AppId,
|
|
user_id: AdminUserId,
|
|
role: AppRole,
|
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
|
"UPDATE app_members SET role = $1 \
|
|
WHERE app_id = $2 AND user_id = $3 \
|
|
RETURNING app_id, user_id, role, created_at",
|
|
)
|
|
.bind(role.as_str())
|
|
.bind(app_id.into_inner())
|
|
.bind(user_id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(TryInto::try_into).transpose()
|
|
}
|
|
|
|
async fn list_for_user(
|
|
&self,
|
|
user_id: AdminUserId,
|
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
|
"SELECT app_id, user_id, role, created_at \
|
|
FROM app_members WHERE user_id = $1 \
|
|
ORDER BY created_at",
|
|
)
|
|
.bind(user_id.into_inner())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
rows.into_iter().map(TryInto::try_into).collect()
|
|
}
|
|
|
|
async fn list_for_app(
|
|
&self,
|
|
app_id: AppId,
|
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
|
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
|
"SELECT app_id, user_id, role, created_at \
|
|
FROM app_members WHERE app_id = $1 \
|
|
ORDER BY created_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
rows.into_iter().map(TryInto::try_into).collect()
|
|
}
|
|
|
|
async fn list_for_app_enriched(
|
|
&self,
|
|
app_id: AppId,
|
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
|
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
|
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
|
am.role, am.created_at \
|
|
FROM app_members am \
|
|
JOIN admin_users au ON au.id = am.user_id \
|
|
WHERE am.app_id = $1 \
|
|
ORDER BY au.username",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
rows.into_iter().map(TryInto::try_into).collect()
|
|
}
|
|
}
|
|
|
|
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
|
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
|
|
/// it to `authz::can` without casting.
|
|
#[async_trait]
|
|
impl AuthzRepo for PostgresAppMembersRepository {
|
|
async fn membership(
|
|
&self,
|
|
user_id: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
self.find(user_id, app_id)
|
|
.await
|
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
|
}
|
|
|
|
/// Highest effective role on `app_id`: one recursive CTE walks the
|
|
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
|
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
|
/// every ancestor `group_members` row by authority rank. A single
|
|
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
|
async fn effective_app_role(
|
|
&self,
|
|
user_id: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
|
"WITH RECURSIVE ancestors AS (
|
|
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
|
UNION ALL
|
|
SELECT g.parent_id, anc.depth + 1
|
|
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
|
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
|
),
|
|
{ROLE_RANK_CTE}
|
|
SELECT eff.role
|
|
FROM (
|
|
SELECT am.role FROM app_members am
|
|
WHERE am.user_id = $1 AND am.app_id = $2
|
|
UNION ALL
|
|
SELECT gm.role FROM group_members gm
|
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
|
WHERE gm.user_id = $1
|
|
) eff
|
|
JOIN role_rank rr ON rr.role = eff.role
|
|
ORDER BY rr.rank DESC
|
|
LIMIT 1"
|
|
))
|
|
.bind(user_id.into_inner())
|
|
.bind(app_id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
|
row.map(|(role,)| {
|
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
|
"invalid role {role:?} in members table"
|
|
)))
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
/// Highest effective role on a *group* node — walks the group's own
|
|
/// ancestor chain over `group_members`. Gates group management.
|
|
async fn effective_group_role(
|
|
&self,
|
|
user_id: UserId,
|
|
group_id: GroupId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
|
"WITH RECURSIVE ancestors AS (
|
|
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
|
UNION ALL
|
|
SELECT g.id, g.parent_id, anc.depth + 1
|
|
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
|
WHERE anc.depth < 64
|
|
),
|
|
{ROLE_RANK_CTE}
|
|
SELECT gm.role
|
|
FROM group_members gm
|
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
|
JOIN role_rank rr ON rr.role = gm.role
|
|
WHERE gm.user_id = $1
|
|
ORDER BY rr.rank DESC
|
|
LIMIT 1"
|
|
))
|
|
.bind(user_id.into_inner())
|
|
.bind(group_id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
|
row.map(|(role,)| {
|
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
|
"invalid role {role:?} in group_members table"
|
|
)))
|
|
})
|
|
.transpose()
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AppMembershipRecord {
|
|
app_id: uuid::Uuid,
|
|
user_id: uuid::Uuid,
|
|
role: String,
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
|
type Error = AppMembersRepositoryError;
|
|
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
app_id: r.app_id.into(),
|
|
user_id: r.user_id.into(),
|
|
role: AppRole::from_db_str(&r.role)
|
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
|
created_at: r.created_at,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AppMembershipDetailRecord {
|
|
id: uuid::Uuid,
|
|
username: String,
|
|
email: Option<String>,
|
|
instance_role: String,
|
|
is_active: bool,
|
|
role: String,
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
|
type Error = AppMembersRepositoryError;
|
|
fn try_from(r: AppMembershipDetailRecord) -> 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(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
|
is_active: r.is_active,
|
|
role: AppRole::from_db_str(&r.role)
|
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
|
created_at: r.created_at,
|
|
})
|
|
}
|
|
}
|