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

users_admin_api router merged into the guarded admin tree at
/api/v1/admin/apps/{id_or_slug}/users/* + /invitations/*.

Endpoints (capability gates applied via the service layer):

  GET    /users                                       AppUsersRead
  GET    /users/{user_id}                             AppUsersRead
  POST   /users                                       AppUsersWrite
  PATCH  /users/{user_id}                             AppUsersWrite
  DELETE /users/{user_id}                             AppUsersWrite (admins satisfy implicitly)
  POST   /users/{user_id}/reset-password              AppUsersAdmin -> one-shot token
  POST   /users/{user_id}/revoke-sessions             AppUsersAdmin
  GET    /invitations                                 AppUsersAdmin
  POST   /invitations                                 AppUsersAdmin
  DELETE /invitations/{invite_id}                     AppUsersAdmin

DTOs never include password_hash, session tokens, or unrotated
reset tokens. The reset-password endpoint returns the raw one-shot
token exactly once in the response body so an admin can paste it
into a manual reset link (TTL 1h by default).

Synth_cx() factors the admin-side cx construction into one place
(marked) so the SDK and admin code paths share the service's authz
fan-out. Admin-mediated trait methods (admin_create_invitation,
admin_reset_password_token, admin_revoke_all_sessions,
list_invitations, revoke_invitation) take &Principal directly,
not the synthesized cx.

UsersServiceImpl: removed the NOT_YET_IMPL constant + unused
auth alias (every method has a real implementation now). Added
Serialize+Deserialize on the AppUser / Invitation shared DTOs so
the admin HTTP layer doesn't need a parallel set of types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 12:18:29 +02:00
parent 3af99873c3
commit aa2631ff61
5 changed files with 621 additions and 23 deletions

View File

@@ -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;
}