//! `/api/v1/admin/apps/{id_or_slug}/users/*` + `/invitations/*` — //! admin HTTP surface for v1.1.8 user management. //! //! All endpoints are capability-gated against the resolved //! `app_id` (extracted from the path then validated via the apps //! repo — never trusted at face value). Read vs Write vs Admin //! mirrors the SDK's gating from the brief. //! //! No DTO ever returns a password hash, a session token, or a long- //! lived reset token. The single exception is //! `POST /users/{user_id}/reset-password` which returns the one-shot //! reset token in its body so an admin can paste it into a manual //! reset link (TTL 1h by default). use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::{Extension, Router}; use picloud_shared::{ AppId, AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, Invitation, InvitationId, InviteOpts, Principal, UpdateUserInput, UsersError, UsersListOpts, UsersService, }; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::app_repo::AppRepository; use crate::authz::{self, AuthzDenied, AuthzRepo, Capability}; use crate::repo::ScriptRepositoryError; #[derive(Clone)] pub struct AppUsersState { pub apps: Arc, pub authz: Arc, pub users: Arc, } pub fn app_users_router(state: AppUsersState) -> Router { Router::new() .route( "/apps/{id_or_slug}/users", get(list_users).post(create_user), ) .route( "/apps/{id_or_slug}/users/{user_id}", get(get_user).patch(patch_user).delete(delete_user), ) .route( "/apps/{id_or_slug}/users/{user_id}/reset-password", post(reset_password), ) .route( "/apps/{id_or_slug}/users/{user_id}/revoke-sessions", post(revoke_sessions), ) .route( "/apps/{id_or_slug}/invitations", get(list_invitations_handler).post(create_invitation_handler), ) .route( "/apps/{id_or_slug}/invitations/{invite_id}", delete(revoke_invitation_handler), ) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct ListUsersResponse { pub users: Vec, /// F-P-012: opaque `_` cursor with id tiebreaker. pub next_cursor: Option, } #[derive(Debug, Deserialize)] pub struct ListUsersQuery { /// F-P-012: opaque `_` cursor with id tiebreaker. pub cursor: Option, pub limit: Option, } #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub email: String, pub password: String, pub display_name: Option, } #[derive(Debug, Deserialize, Default)] pub struct PatchUserRequest { /// `Some(Some(s))` to set, `Some(None)` to clear, absent to leave /// alone. Standard `serde` triple-state. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option>, } #[derive(Debug, Serialize)] pub struct ResetPasswordResponse { /// The one-shot raw token. Returned exactly once — the admin /// pastes it into a manual reset link. Never logged server-side. pub token: String, pub expires_in_seconds: i64, } #[derive(Debug, Serialize)] pub struct RevokeSessionsResponse { pub revoked: u64, } #[derive(Debug, Serialize)] pub struct ListInvitationsResponse { pub invitations: Vec, } #[derive(Debug, Deserialize)] pub struct CreateInvitationRequest { pub email: String, pub display_name: Option, #[serde(default)] pub roles: Vec, /// Optional email template — when present, the admin send goes /// out under the admin's own AppEmailSend gate. Omit to issue an /// invitation without sending an email (out-of-band delivery). pub template: Option, } #[derive(Debug, Deserialize)] pub struct EmailTemplateRequest { pub link_base: String, pub from: String, pub subject: String, pub body_template: String, } impl From for EmailTemplateOpts { fn from(r: EmailTemplateRequest) -> Self { Self { link_base: r.link_base, from: r.from, subject: r.subject, body_template: r.body_template, } } } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_users( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; // The capability check happens inside the service via cx-style // require(); here we synthesize the admin-mediated call via the // service's list path. To use the existing service.list, we need // an SdkCallCx — but for admin routes we'd rather not synthesize // one. So we go through a small adapter: call into a list helper // that does its own require, mirroring how admin_create_invitation // works. For commit 10 the simplest path is a service-level // admin_list method; until that exists we synthesize a cx. // // Decision: do the synthesis here (one place, well-marked) rather // than fan a second list method out to the trait. The cx carries // the admin's principal so the service's authz fires exactly as // the brief specifies. let cx = synth_cx(app.id, &principal); let page = s .users .list( &cx, UsersListOpts { cursor: q.cursor, limit: q.limit, }, ) .await?; Ok(Json(ListUsersResponse { users: page.items, next_cursor: page.next_cursor, })) } async fn get_user( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let cx = synth_cx(app.id, &principal); let user = s .users .get(&cx, AppUserId::from(user_id)) .await? .ok_or(AppUsersApiError::UserNotFound)?; Ok(Json(user)) } async fn create_user( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let cx = synth_cx(app.id, &principal); let user = s .users .create( &cx, CreateUserInput { email: input.email, password: input.password, display_name: input.display_name, }, ) .await?; Ok((StatusCode::CREATED, Json(user))) } async fn patch_user( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, Json(input): Json, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let cx = synth_cx(app.id, &principal); let user = s .users .update( &cx, AppUserId::from(user_id), UpdateUserInput { display_name: input.display_name, }, ) .await?; Ok(Json(user)) } async fn delete_user( 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?; // F-S-005: gate on the stronger AppUsersAdmin per the original brief // — the dashboard delete is irreversible. The service-layer // `delete` only checks AppUsersWrite; without this explicit gate, // any editor could delete app users via the admin HTTP API. authz::require( s.authz.as_ref(), &principal, Capability::AppUsersAdmin(app.id), ) .await?; let cx = synth_cx(app.id, &principal); let removed = s.users.delete(&cx, AppUserId::from(user_id)).await?; if removed { Ok(StatusCode::NO_CONTENT) } else { Err(AppUsersApiError::UserNotFound) } } async fn reset_password( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let token = s .users .admin_reset_password_token(&principal, app.id, AppUserId::from(user_id)) .await?; Ok(Json(ResetPasswordResponse { token, // 1h default; if the operator overrode the env var the // returned value is the same the migrations enforce. expires_in_seconds: 3600, })) } async fn revoke_sessions( State(s): State, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let revoked = s .users .admin_revoke_all_sessions(&principal, app.id, AppUserId::from(user_id)) .await?; Ok(Json(RevokeSessionsResponse { revoked })) } async fn list_invitations_handler( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let invitations = s.users.list_invitations(&principal, app.id).await?; Ok(Json(ListInvitationsResponse { invitations })) } async fn create_invitation_handler( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), AppUsersApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?; let opts = InviteOpts { template: input.template.map(Into::into), display_name: input.display_name, roles: input.roles, }; let invitation = s .users .admin_create_invitation(&principal, app.id, &input.email, opts) .await?; Ok((StatusCode::CREATED, Json(invitation))) } async fn revoke_invitation_handler( State(s): State, Extension(principal): Extension, Path((id_or_slug, invite_id)): Path<(String, Uuid)>, ) -> Result { let app = resolve_app(&*s.apps, &id_or_slug).await?; let removed = s .users .revoke_invitation(&principal, app.id, InvitationId::from(invite_id)) .await?; if removed { Ok(StatusCode::NO_CONTENT) } else { Err(AppUsersApiError::InvitationNotFound) } } // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- /// Synthesize an `SdkCallCx` for an admin HTTP request. The CRUD /// users::* SDK methods take an `SdkCallCx`; here the dashboard /// admin is the caller. We synthesize a cx carrying the admin's /// principal so the service's existing capability checks fire /// uniformly across SDK and admin code paths. fn synth_cx(app_id: AppId, principal: &Principal) -> picloud_shared::SdkCallCx { picloud_shared::SdkCallCx { app_id, principal: Some(principal.clone()), script_id: picloud_shared::ScriptId::new(), execution_id: picloud_shared::ExecutionId::new(), request_id: picloud_shared::RequestId::new(), trigger_depth: 0, root_execution_id: picloud_shared::ExecutionId::new(), is_dead_letter_handler: false, event: None, } } 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(|| AppUsersApiError::AppNotFound(ident.to_string())) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum AppUsersApiError { #[error("app not found: {0}")] AppNotFound(String), #[error("user not found")] UserNotFound, #[error("invitation not found")] InvitationNotFound, #[error("forbidden")] Forbidden, #[error("{0}")] Invalid(String), #[error("email already in use")] DuplicateEmail, #[error("email is not configured")] EmailNotConfigured, #[error("email send rate-limited")] EmailRateLimited, #[error("backend error: {0}")] Backend(String), #[error("repository error: {0}")] Apps(#[from] ScriptRepositoryError), } impl From for AppUsersApiError { fn from(e: UsersError) -> Self { match e { UsersError::Forbidden => Self::Forbidden, UsersError::NotFound => Self::UserNotFound, UsersError::DuplicateEmail => Self::DuplicateEmail, UsersError::Invalid(msg) => Self::Invalid(msg), UsersError::EmailNotConfigured => Self::EmailNotConfigured, UsersError::EmailRateLimited => Self::EmailRateLimited, UsersError::EmailTransport(msg) | UsersError::Backend(msg) => Self::Backend(msg), } } } impl From for AppUsersApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::Backend(e.to_string()), } } } impl IntoResponse for AppUsersApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) | Self::UserNotFound | Self::InvitationNotFound | Self::Apps(ScriptRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::Invalid(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::DuplicateEmail | Self::Apps(ScriptRepositoryError::Conflict(_)) => { (StatusCode::CONFLICT, json!({ "error": self.to_string() })) } Self::EmailNotConfigured => ( StatusCode::SERVICE_UNAVAILABLE, json!({ "error": self.to_string() }), ), Self::EmailRateLimited => ( StatusCode::TOO_MANY_REQUESTS, json!({ "error": self.to_string() }), ), Self::Backend(e) => { tracing::error!(error = %e, "app users admin backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Apps(ScriptRepositoryError::Db(e)) => { tracing::error!(error = %e, "app users admin apps repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }