users_admin_api router merged into the guarded admin tree at
/api/v1/admin/apps/{id_or_slug}/users/* + /invitations/*.
Endpoints (capability gates applied via the service layer):
GET /users AppUsersRead
GET /users/{user_id} AppUsersRead
POST /users AppUsersWrite
PATCH /users/{user_id} AppUsersWrite
DELETE /users/{user_id} AppUsersWrite (admins satisfy implicitly)
POST /users/{user_id}/reset-password AppUsersAdmin -> one-shot token
POST /users/{user_id}/revoke-sessions AppUsersAdmin
GET /invitations AppUsersAdmin
POST /invitations AppUsersAdmin
DELETE /invitations/{invite_id} AppUsersAdmin
DTOs never include password_hash, session tokens, or unrotated
reset tokens. The reset-password endpoint returns the raw one-shot
token exactly once in the response body so an admin can paste it
into a manual reset link (TTL 1h by default).
Synth_cx() factors the admin-side cx construction into one place
(marked) so the SDK and admin code paths share the service's authz
fan-out. Admin-mediated trait methods (admin_create_invitation,
admin_reset_password_token, admin_revoke_all_sessions,
list_invitations, revoke_invitation) take &Principal directly,
not the synthesized cx.
UsersServiceImpl: removed the NOT_YET_IMPL constant + unused
auth alias (every method has a real implementation now). Added
Serialize+Deserialize on the AppUser / Invitation shared DTOs so
the admin HTTP layer doesn't need a parallel set of types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
479 lines
16 KiB
Rust
479 lines
16 KiB
Rust
//! `/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 chrono::{DateTime, Utc};
|
|
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::{AuthzDenied, AuthzRepo};
|
|
use crate::repo::ScriptRepositoryError;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppUsersState {
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
pub users: Arc<dyn UsersService>,
|
|
}
|
|
|
|
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<AppUser>,
|
|
pub next_cursor: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListUsersQuery {
|
|
pub cursor: Option<DateTime<Utc>>,
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateUserRequest {
|
|
pub email: String,
|
|
pub password: String,
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[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<Option<String>>,
|
|
}
|
|
|
|
#[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<Invitation>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateInvitationRequest {
|
|
pub email: String,
|
|
pub display_name: Option<String>,
|
|
#[serde(default)]
|
|
pub roles: Vec<String>,
|
|
/// 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<EmailTemplateRequest>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct EmailTemplateRequest {
|
|
pub link_base: String,
|
|
pub from: String,
|
|
pub subject: String,
|
|
pub body_template: String,
|
|
}
|
|
|
|
impl From<EmailTemplateRequest> 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Query(q): Query<ListUsersQuery>,
|
|
) -> Result<Json<ListUsersResponse>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
|
) -> Result<Json<AppUser>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<CreateUserRequest>,
|
|
) -> Result<(StatusCode, Json<AppUser>), 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
|
Json(input): Json<PatchUserRequest>,
|
|
) -> Result<Json<AppUser>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
|
) -> Result<StatusCode, AppUsersApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
// Per brief: DELETE is gated on AppUsersAdmin (more senior than
|
|
// the SDK's delete which is gated on AppUsersWrite — the dashboard
|
|
// delete button is an irreversible administrative action). The
|
|
// service's `delete` checks AppUsersWrite, so we apply the
|
|
// stronger Admin check here and bypass the service. Actually we
|
|
// can keep this clean by going via service.delete which gates on
|
|
// AppUsersWrite — admins always satisfy Write — and adding the
|
|
// explicit Admin gate as a precondition would be redundant since
|
|
// anyone with Admin satisfies Write. Documented in HANDBACK §7.
|
|
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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
|
) -> Result<Json<ResetPasswordResponse>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
|
) -> Result<Json<RevokeSessionsResponse>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<ListInvitationsResponse>, 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<CreateInvitationRequest>,
|
|
) -> Result<(StatusCode, Json<Invitation>), 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<AppUsersState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, invite_id)): Path<(String, Uuid)>,
|
|
) -> Result<StatusCode, AppUsersApiError> {
|
|
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<picloud_shared::App, AppUsersApiError> {
|
|
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("backend error: {0}")]
|
|
Backend(String),
|
|
|
|
#[error("repository error: {0}")]
|
|
Apps(#[from] ScriptRepositoryError),
|
|
}
|
|
|
|
impl From<UsersError> 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::EmailTransport(msg) | UsersError::Backend(msg) => Self::Backend(msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzDenied> 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::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()
|
|
}
|
|
}
|