diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 7341efa..01c2a25 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod app_user_repo; pub mod app_user_role_repo; pub mod app_user_session_repo; pub mod app_user_verification_repo; +pub mod users_admin_api; pub mod users_service; pub mod apps_api; pub mod auth; @@ -99,6 +100,7 @@ pub use app_user_password_reset_repo::{ AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo, }; 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::{ AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo, }; diff --git a/crates/manager-core/src/users_admin_api.rs b/crates/manager-core/src/users_admin_api.rs new file mode 100644 index 0000000..f9c9578 --- /dev/null +++ b/crates/manager-core/src/users_admin_api.rs @@ -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, + 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, + pub next_cursor: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct ListUsersQuery { + 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?; + // 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, + 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("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::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::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() + } +} diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index 731c87b..191075b 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -44,12 +44,10 @@ use crate::app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError}; use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError}; use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError}; 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}; -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 /// via env vars wired in the picloud binary. #[derive(Debug, Clone, Copy)] @@ -909,20 +907,119 @@ impl UsersService for UsersServiceImpl { } async fn admin_reset_password_token( &self, - _principal: &Principal, - _app_id: AppId, - _user_id: AppUserId, + principal: &Principal, + app_id: AppId, + user_id: AppUserId, ) -> Result { - 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( &self, - _principal: &Principal, - _app_id: AppId, - _user_id: AppUserId, + principal: &Principal, + app_id: AppId, + user_id: AppUserId, ) -> Result { - 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 { + 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( &self, 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; -} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 1b9daea..5eb792c 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -282,7 +282,7 @@ pub async fn build_app( pubsub, secrets, email, - users, + users.clone(), ); let engine = Arc::new(Engine::new(Limits::default(), services)); @@ -448,9 +448,14 @@ pub async fn build_app( }; let app_members_state = AppMembersState { apps: apps_state.apps.clone(), - users: auth.users, + users: auth.users.clone(), 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 }; @@ -466,6 +471,7 @@ pub async fn build_app( .merge(admins_router(admins_state)) .merge(apps_router(apps_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(triggers_router(triggers_state)) .merge(files_admin_router(files_admin_state)) diff --git a/crates/shared/src/users.rs b/crates/shared/src/users.rs index 37a2902..3b8d77b 100644 --- a/crates/shared/src/users.rs +++ b/crates/shared/src/users.rs @@ -19,6 +19,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use thiserror::Error; 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 /// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage; /// pre-commit-9 it's always empty). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppUser { pub id: AppUserId, pub app_id: AppId, @@ -41,7 +42,7 @@ pub struct AppUser { } /// 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 id: InvitationId, pub app_id: AppId, @@ -336,6 +337,18 @@ pub trait UsersService: Send + Sync { user_id: AppUserId, ) -> Result; + /// 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; + /// Admin lists pending invitations for the app's invitations tab. async fn list_invitations( &self, @@ -515,6 +528,15 @@ impl UsersService for NoopUsersService { ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } + async fn admin_create_invitation( + &self, + _principal: &Principal, + _app_id: AppId, + _email: &str, + _opts: InviteOpts, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } async fn list_invitations( &self, _principal: &Principal,