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_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,
|
||||
};
|
||||
|
||||
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_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<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(
|
||||
&self,
|
||||
_principal: &Principal,
|
||||
_app_id: AppId,
|
||||
_user_id: AppUserId,
|
||||
principal: &Principal,
|
||||
app_id: AppId,
|
||||
user_id: AppUserId,
|
||||
) -> 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(
|
||||
&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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user