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:
478
crates/manager-core/src/users_admin_api.rs
Normal file
478
crates/manager-core/src/users_admin_api.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user