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:
@@ -23,6 +23,7 @@ pub mod app_user_repo;
|
|||||||
pub mod app_user_role_repo;
|
pub mod app_user_role_repo;
|
||||||
pub mod app_user_session_repo;
|
pub mod app_user_session_repo;
|
||||||
pub mod app_user_verification_repo;
|
pub mod app_user_verification_repo;
|
||||||
|
pub mod users_admin_api;
|
||||||
pub mod users_service;
|
pub mod users_service;
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
@@ -99,6 +100,7 @@ pub use app_user_password_reset_repo::{
|
|||||||
AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo,
|
AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo,
|
||||||
};
|
};
|
||||||
pub use app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError, PostgresAppUserRoleRepo};
|
pub use app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError, PostgresAppUserRoleRepo};
|
||||||
|
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||||
pub use app_user_verification_repo::{
|
pub use app_user_verification_repo::{
|
||||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||||
};
|
};
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,10 @@ use crate::app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError};
|
|||||||
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
|
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
|
||||||
use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError};
|
use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError};
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
self, generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH,
|
generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH,
|
||||||
};
|
};
|
||||||
use crate::authz::{self, AuthzDenied, AuthzRepo, Capability};
|
use crate::authz::{self, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
|
||||||
const NOT_YET_IMPL: &str = "users::* feature not yet implemented in this v1.1.8 commit";
|
|
||||||
|
|
||||||
/// Runtime configuration. Defaults match the v1.1.8 brief — overridable
|
/// Runtime configuration. Defaults match the v1.1.8 brief — overridable
|
||||||
/// via env vars wired in the picloud binary.
|
/// via env vars wired in the picloud binary.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -909,20 +907,119 @@ impl UsersService for UsersServiceImpl {
|
|||||||
}
|
}
|
||||||
async fn admin_reset_password_token(
|
async fn admin_reset_password_token(
|
||||||
&self,
|
&self,
|
||||||
_principal: &Principal,
|
principal: &Principal,
|
||||||
_app_id: AppId,
|
app_id: AppId,
|
||||||
_user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
) -> Result<String, UsersError> {
|
) -> Result<String, UsersError> {
|
||||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
self.require(Some(principal), Capability::AppUsersAdmin(app_id))
|
||||||
|
.await?;
|
||||||
|
// Verify the user exists in this app — surfaces 404 cleanly
|
||||||
|
// instead of failing on the FK insert.
|
||||||
|
let exists = self.users.get(app_id, user_id).await?.is_some();
|
||||||
|
if !exists {
|
||||||
|
return Err(UsersError::NotFound);
|
||||||
|
}
|
||||||
|
let token = generate_session_token();
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
+ chrono::Duration::from_std(self.config.password_reset_ttl)
|
||||||
|
.map_err(|e| UsersError::Backend(format!("reset ttl: {e}")))?;
|
||||||
|
self.password_resets
|
||||||
|
.create(app_id, user_id, &token.hash, expires_at)
|
||||||
|
.await?;
|
||||||
|
// No email send — the admin pastes the raw token into their
|
||||||
|
// own out-of-band reset link. The script-side
|
||||||
|
// users::request_password_reset is the email-bearing path.
|
||||||
|
Ok(token.raw)
|
||||||
}
|
}
|
||||||
async fn admin_revoke_all_sessions(
|
async fn admin_revoke_all_sessions(
|
||||||
&self,
|
&self,
|
||||||
_principal: &Principal,
|
principal: &Principal,
|
||||||
_app_id: AppId,
|
app_id: AppId,
|
||||||
_user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
) -> Result<u64, UsersError> {
|
) -> Result<u64, UsersError> {
|
||||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
self.require(Some(principal), Capability::AppUsersAdmin(app_id))
|
||||||
|
.await?;
|
||||||
|
Ok(self.sessions.revoke_for_user(app_id, user_id).await?)
|
||||||
}
|
}
|
||||||
|
async fn admin_create_invitation(
|
||||||
|
&self,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: AppId,
|
||||||
|
email: &str,
|
||||||
|
opts: InviteOpts,
|
||||||
|
) -> Result<Invitation, UsersError> {
|
||||||
|
self.require(Some(principal), Capability::AppUsersAdmin(app_id))
|
||||||
|
.await?;
|
||||||
|
let normalized = validate_email(email)?;
|
||||||
|
let display_name = validate_display_name(opts.display_name.as_deref())?;
|
||||||
|
let token = generate_session_token();
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
+ chrono::Duration::from_std(self.config.invitation_ttl)
|
||||||
|
.map_err(|e| UsersError::Backend(format!("invitation ttl: {e}")))?;
|
||||||
|
let row = self
|
||||||
|
.invitations
|
||||||
|
.create(
|
||||||
|
app_id,
|
||||||
|
&normalized,
|
||||||
|
display_name.as_deref(),
|
||||||
|
&opts.roles,
|
||||||
|
&token.hash,
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(template) = opts.template {
|
||||||
|
let link = build_link(&template.link_base, &token.raw);
|
||||||
|
let body = template.body_template.replace("{link}", &link);
|
||||||
|
let outbound = OutboundEmail {
|
||||||
|
to: vec![normalized.clone()],
|
||||||
|
from: template.from,
|
||||||
|
subject: template.subject,
|
||||||
|
text: Some(body),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Admin-issued: no SdkCallCx exists; synthesize a minimal
|
||||||
|
// one with the admin's principal so the email service's
|
||||||
|
// own authz pass is honored. (For users::invite the cx is
|
||||||
|
// the script's; the internal_cx() helper there clears the
|
||||||
|
// principal because the users::* gate already fired.
|
||||||
|
// Here the admin's principal is the actual caller of the
|
||||||
|
// email — keep the gate.)
|
||||||
|
let send_cx = 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,
|
||||||
|
};
|
||||||
|
if let Err(e) = self.email.send(&send_cx, outbound).await {
|
||||||
|
if matches!(e, EmailError::NotConfigured) {
|
||||||
|
return Err(UsersError::EmailNotConfigured);
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
invite_id = %row.id,
|
||||||
|
app_id = %app_id,
|
||||||
|
"admin_create_invitation email failed; row remains valid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Invitation {
|
||||||
|
id: row.id,
|
||||||
|
app_id: row.app_id,
|
||||||
|
email: row.email,
|
||||||
|
display_name: row.display_name,
|
||||||
|
roles: row.roles,
|
||||||
|
created_at: row.created_at,
|
||||||
|
expires_at: row.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_invitations(
|
async fn list_invitations(
|
||||||
&self,
|
&self,
|
||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
@@ -1023,10 +1120,3 @@ impl UsersServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silence unused warnings on the public `auth` re-import so the file
|
|
||||||
// stays clippy-clean while the password-reset commit prepares to use
|
|
||||||
// the dummy hash directly from this module.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn _ensure_auth_in_scope() {
|
|
||||||
let _ = auth::hash_token;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ pub async fn build_app(
|
|||||||
pubsub,
|
pubsub,
|
||||||
secrets,
|
secrets,
|
||||||
email,
|
email,
|
||||||
users,
|
users.clone(),
|
||||||
);
|
);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
|
|
||||||
@@ -448,9 +448,14 @@ pub async fn build_app(
|
|||||||
};
|
};
|
||||||
let app_members_state = AppMembersState {
|
let app_members_state = AppMembersState {
|
||||||
apps: apps_state.apps.clone(),
|
apps: apps_state.apps.clone(),
|
||||||
users: auth.users,
|
users: auth.users.clone(),
|
||||||
members,
|
members,
|
||||||
authz,
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
users: users.clone(),
|
||||||
};
|
};
|
||||||
let api_keys_state = ApiKeysState { keys: auth.keys };
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
|
|
||||||
@@ -466,6 +471,7 @@ pub async fn build_app(
|
|||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
.merge(app_members_router(app_members_state))
|
.merge(app_members_router(app_members_state))
|
||||||
|
.merge(picloud_manager_core::app_users_router(app_users_admin_state))
|
||||||
.merge(api_keys_router(api_keys_state))
|
.merge(api_keys_router(api_keys_state))
|
||||||
.merge(triggers_router(triggers_state))
|
.merge(triggers_router(triggers_state))
|
||||||
.merge(files_admin_router(files_admin_state))
|
.merge(files_admin_router(files_admin_state))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};
|
use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};
|
||||||
@@ -27,7 +28,7 @@ use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};
|
|||||||
/// / `list` / etc. Never carries the password hash. The `roles` field
|
/// / `list` / etc. Never carries the password hash. The `roles` field
|
||||||
/// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage;
|
/// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage;
|
||||||
/// pre-commit-9 it's always empty).
|
/// pre-commit-9 it's always empty).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct AppUser {
|
pub struct AppUser {
|
||||||
pub id: AppUserId,
|
pub id: AppUserId,
|
||||||
pub app_id: AppId,
|
pub app_id: AppId,
|
||||||
@@ -41,7 +42,7 @@ pub struct AppUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pending invitation row returned by the admin invitations API.
|
/// Pending invitation row returned by the admin invitations API.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Invitation {
|
pub struct Invitation {
|
||||||
pub id: InvitationId,
|
pub id: InvitationId,
|
||||||
pub app_id: AppId,
|
pub app_id: AppId,
|
||||||
@@ -336,6 +337,18 @@ pub trait UsersService: Send + Sync {
|
|||||||
user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
) -> Result<u64, UsersError>;
|
) -> Result<u64, UsersError>;
|
||||||
|
|
||||||
|
/// Admin issues an invitation from the dashboard (distinct from
|
||||||
|
/// the SDK `users::invite` path, which takes an SdkCallCx tied to
|
||||||
|
/// the calling script). Returns the created invitation so the
|
||||||
|
/// admin UI can render it without a re-fetch.
|
||||||
|
async fn admin_create_invitation(
|
||||||
|
&self,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: AppId,
|
||||||
|
email: &str,
|
||||||
|
opts: InviteOpts,
|
||||||
|
) -> Result<Invitation, UsersError>;
|
||||||
|
|
||||||
/// Admin lists pending invitations for the app's invitations tab.
|
/// Admin lists pending invitations for the app's invitations tab.
|
||||||
async fn list_invitations(
|
async fn list_invitations(
|
||||||
&self,
|
&self,
|
||||||
@@ -515,6 +528,15 @@ impl UsersService for NoopUsersService {
|
|||||||
) -> Result<u64, UsersError> {
|
) -> Result<u64, UsersError> {
|
||||||
Err(UsersError::Backend(NOOP_MSG.into()))
|
Err(UsersError::Backend(NOOP_MSG.into()))
|
||||||
}
|
}
|
||||||
|
async fn admin_create_invitation(
|
||||||
|
&self,
|
||||||
|
_principal: &Principal,
|
||||||
|
_app_id: AppId,
|
||||||
|
_email: &str,
|
||||||
|
_opts: InviteOpts,
|
||||||
|
) -> Result<Invitation, UsersError> {
|
||||||
|
Err(UsersError::Backend(NOOP_MSG.into()))
|
||||||
|
}
|
||||||
async fn list_invitations(
|
async fn list_invitations(
|
||||||
&self,
|
&self,
|
||||||
_principal: &Principal,
|
_principal: &Principal,
|
||||||
|
|||||||
Reference in New Issue
Block a user