feat(v1.1.8): admin HTTP — /apps/{id}/users + /invitations

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>
This commit is contained in:
MechaCat02
2026-06-06 12:18:29 +02:00
parent 3af99873c3
commit aa2631ff61
5 changed files with 621 additions and 23 deletions

View File

@@ -0,0 +1,478 @@
//! `/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()
}
}