//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the //! `app_members` table (blueprint §11.6). //! //! Every endpoint is gated on `Capability::AppAdmin(app_id)` after //! resolving the app from `id_or_slug`. Editors and viewers receive //! 403 from list and never see the dashboard's Members tab. //! //! POST is **non-idempotent on purpose**: a duplicate `(app_id, //! user_id)` returns 409 rather than upsert-200, so the UI can show //! "already a member — promote / demote them instead" cleanly. Role //! changes go through PATCH. //! //! No last-app-admin guard: owners always implicitly satisfy //! `Capability::AppAdmin(_)` (authz::role_grants), so removing the //! final explicit `app_admin` membership cannot orphan an app. use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, patch}; use axum::{Extension, Router}; use chrono::{DateTime, Utc}; use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; use crate::app_members_repo::{ AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow, }; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::repo::ScriptRepositoryError; #[derive(Clone)] pub struct AppMembersState { pub apps: Arc, pub users: Arc, pub members: Arc, pub authz: Arc, } pub fn app_members_router(state: AppMembersState) -> Router { Router::new() .route( "/apps/{id_or_slug}/members", get(list_members).post(grant_member), ) .route( "/apps/{id_or_slug}/members/{user_id}", patch(patch_member).delete(remove_member), ) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct AppMemberDto { 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, } impl From for AppMemberDto { fn from(d: AppMembershipDetail) -> Self { Self { user_id: d.user_id, username: d.username, email: d.email, instance_role: d.instance_role, is_active: d.is_active, role: d.role, created_at: d.created_at, } } } /// Compose a DTO from an `AdminUserRow` (fetched for validation) and /// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on /// POST/PATCH at the cost of trusting the two inputs reference the /// same user_id — caller's responsibility. fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto { AppMemberDto { user_id: user.id, username: user.username, email: user.email, instance_role: user.instance_role, is_active: user.is_active, role: membership.role, created_at: membership.created_at, } } #[derive(Debug, Deserialize)] pub struct GrantMemberRequest { pub user_id: AdminUserId, pub role: AppRole, } #[derive(Debug, Deserialize)] pub struct PatchMemberRequest { pub role: AppRole, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_members( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, AppMembersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; let rows = s.members.list_for_app_enriched(app.id).await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } async fn grant_member( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), AppMembersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; let user = s .users .get(input.user_id) .await? .ok_or(AppMembersApiError::UserNotFound(input.user_id))?; validate_grant_target(&user)?; // Atomic insert — if a row already exists, returns None and we 409. // Avoids the find-then-upsert race where two concurrent POSTs would // both pass the existence check and the second `upsert` would // silently rewrite the role. let row = s .members .try_insert(app.id, user.id, input.role) .await? .ok_or_else(|| AppMembersApiError::AlreadyMember { username: user.username.clone(), })?; Ok((StatusCode::CREATED, Json(compose_dto(user, row)))) } async fn patch_member( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, Json(input): Json, ) -> Result, AppMembersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; let user_id = AdminUserId::from(user_id); let user = s .users .get(user_id) .await? .ok_or(AppMembersApiError::UserNotFound(user_id))?; // Atomic update — returns None if no row exists, so 404 is decided // by the same statement that does the write. Eliminates the // find-then-upsert race where a concurrent DELETE between the two // calls would let PATCH silently re-create the row. let row = s .members .update_role(app.id, user_id, input.role) .await? .ok_or(AppMembersApiError::MembershipNotFound)?; Ok(Json(compose_dto(user, row))) } async fn remove_member( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, ) -> Result { let app = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; s.members.remove(app.id, AdminUserId::from(user_id)).await?; Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Validation + helpers // ---------------------------------------------------------------------------- fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> { if !user.is_active { return Err(AppMembersApiError::TargetInactive { username: user.username.clone(), }); } if user.instance_role != InstanceRole::Member { return Err(AppMembersApiError::TargetNotMember { username: user.username.clone(), instance_role: user.instance_role, }); } Ok(()) } async fn resolve_app( apps: &dyn AppRepository, ident: &str, ) -> Result { crate::app_repo::resolve_app(apps, ident) .await? .map(|l| l.app) .ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string())) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum AppMembersApiError { #[error("app not found: {0}")] AppNotFound(String), #[error("user not found: {0}")] UserNotFound(AdminUserId), #[error("no membership exists for this user on this app")] MembershipNotFound, #[error("{username} is already a member of this app — use PATCH to change their role")] AlreadyMember { username: String }, #[error("{username} is deactivated and cannot be added as a member")] TargetInactive { username: String }, #[error( "{username} has instance_role {instance_role:?} and already has implicit access \ on every app — no explicit membership needed" )] TargetNotMember { username: String, instance_role: InstanceRole, }, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("repository error: {0}")] Members(#[from] AppMembersRepositoryError), #[error("user repository error: {0}")] Users(#[from] AdminUserRepositoryError), #[error("repository error: {0}")] Apps(#[from] ScriptRepositoryError), } impl From for AppMembersApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl IntoResponse for AppMembersApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) | Self::UserNotFound(_) | Self::MembershipNotFound | Self::Apps(ScriptRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => { (StatusCode::CONFLICT, json!({ "error": self.to_string() })) } Self::TargetInactive { .. } | Self::TargetNotMember { .. } => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "app members authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Members(e) => { tracing::error!(error = %e, "app members repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Users(e) => { tracing::error!(error = %e, "admin users repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Apps(ScriptRepositoryError::Db(e)) => { tracing::error!(error = %e, "apps repo error in app_members"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }