The previous handlers did `find()` then `upsert()` in two round-trips: - POST: two concurrent grants both pass the duplicate check; the second `upsert` silently rewrites the role instead of returning 409, weakening the "409 on duplicate" contract under load. - PATCH: a concurrent DELETE between `find` and `upsert` makes PATCH silently re-create a row instead of returning 404, weakening the "404 if no existing membership" contract. Adds two repo primitives that fold the check into the write: - `try_insert` — `INSERT ... ON CONFLICT DO NOTHING RETURNING`; None return ⇒ already exists ⇒ 409. - `update_role` — `UPDATE ... WHERE app_id AND user_id RETURNING`; None return ⇒ no row ⇒ 404. Handlers use these directly; existing `upsert` stays for test helpers that genuinely want upsert semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
341 lines
11 KiB
Rust
341 lines
11 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, InstanceRole};
|
|
use sqlx::PgPool;
|
|
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
|
|
#[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: AdminUserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
self.find(user_id, app_id)
|
|
.await
|
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
})
|
|
}
|
|
}
|