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:
@@ -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