//! 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, } /// `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, pub instance_role: InstanceRole, pub is_active: bool, pub role: AppRole, pub created_at: DateTime, } #[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, 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; /// 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, 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, 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, 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, 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, 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, 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 { 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, 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, 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, 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, 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, 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` 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, 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, } impl TryFrom for AppMembershipRow { type Error = AppMembersRepositoryError; fn try_from(r: AppMembershipRecord) -> Result { 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, instance_role: String, is_active: bool, role: String, created_at: DateTime, } impl TryFrom for AppMembershipDetail { type Error = AppMembersRepositoryError; fn try_from(r: AppMembershipDetailRecord) -> Result { 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, }) } }