feat(user): admin can promote external user + security on deletion
promotion by admin of external user into internal possible
deletion of a user request admin to enter it's email, this is to prevent any miss click
This commit is contained in:
@@ -1389,6 +1389,117 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
/// Admin-driven external → internal promotion.
|
||||
///
|
||||
/// Same wire outcome as [`Self::upgrade_to_internal`] but the actor
|
||||
/// is an operator, not the target user. The target's password stays
|
||||
/// as it was (usually `None` — magic-link-only accounts) so the
|
||||
/// deployment MUST have magic-link login enabled, otherwise the
|
||||
/// promoted user has no login path at all.
|
||||
///
|
||||
/// Refuses:
|
||||
/// - Target is already internal → 409 `AlreadyInternal`.
|
||||
/// - Target is OIDC-linked → 403 (IdP owns identity).
|
||||
/// - Magic-link login disabled deployment-wide → 400 with a hint.
|
||||
///
|
||||
/// On success:
|
||||
/// - `is_external → false`, `storage_quota_bytes → capped default`.
|
||||
/// - Home-drive provisioning fires via
|
||||
/// `PersonalDriveLifecycleHook::on_upgraded_to_internal` — same
|
||||
/// hook the self-upgrade path uses.
|
||||
/// - `user_flags_cache` invalidated on the target so per-request
|
||||
/// guards observe the new flag within one cache round-trip.
|
||||
/// - Audit line `event = "user.promoted_to_internal_by_admin"`
|
||||
/// with `by = <admin_id>`, `target_id = <user_id>`.
|
||||
pub async fn admin_promote_external_to_internal(
|
||||
&self,
|
||||
admin_id: Uuid,
|
||||
target_id: Uuid,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
||||
|
||||
if !user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "already_internal",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: target user is already internal",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
"Account is already internal",
|
||||
));
|
||||
}
|
||||
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "oidc_user",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: OIDC-linked user is managed by the IdP",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"SSO/OIDC accounts are managed by your identity provider",
|
||||
));
|
||||
}
|
||||
|
||||
// Admin can't set a password on the target's behalf, so the
|
||||
// upgraded account MUST have magic-link login available on the
|
||||
// deployment — otherwise no login path exists post-promotion.
|
||||
if !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "no_login_path",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: magic-link login disabled and admin can't set the target's password",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Cannot promote: magic-link login is disabled on this deployment, so the user would have no login path.",
|
||||
));
|
||||
}
|
||||
|
||||
let quota = self.capped_quota(&UserRole::User);
|
||||
|
||||
user.promote_to_internal(None, quota).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
format!("Promote refused: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
|
||||
// Invalidate the target's flags cache — same reason as the
|
||||
// self-upgrade path.
|
||||
self.user_flags_cache.invalidate(&target_id).await;
|
||||
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promoted_to_internal_by_admin",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ external user promoted to internal by admin",
|
||||
);
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::{
|
||||
|
||||
use crate::application::dtos::drive_dto::DriveDto;
|
||||
use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto};
|
||||
use crate::application::dtos::user_dto::UserDto;
|
||||
use crate::application::dtos::plugin_dto::{
|
||||
PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto,
|
||||
SetEnabledDto,
|
||||
@@ -68,6 +69,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route("/users/{id}/active", put(update_user_active))
|
||||
.route("/users/{id}/quota", put(update_user_quota))
|
||||
.route("/users/{id}/password", put(reset_user_password))
|
||||
.route(
|
||||
"/users/{id}/promote-to-internal",
|
||||
post(admin_promote_external_to_internal),
|
||||
)
|
||||
// Registration control
|
||||
.route("/settings/registration", put(set_registration_setting))
|
||||
// Audio metadata
|
||||
@@ -1095,6 +1100,48 @@ pub async fn reset_user_password(
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/promote-to-internal — flip an external
|
||||
/// (grant-only) account into a normal internal account, provisioning
|
||||
/// its personal drive on the way. The deployment MUST have magic-link
|
||||
/// login enabled (the admin doesn't set the user's password on their
|
||||
/// behalf, so the promoted user needs some way to log in). Refuses
|
||||
/// OIDC-linked users and users who are already internal.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/users/{id}/promote-to-internal",
|
||||
params(("id" = String, Path, description = "Target user id")),
|
||||
responses(
|
||||
(status = 200, description = "User promoted", body = UserDto),
|
||||
(status = 400, description = "Magic-link login is disabled on this deployment"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required (or target is OIDC-linked)"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 409, description = "User is already internal"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn admin_promote_external_to_internal(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let target_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
|
||||
let dto = auth
|
||||
.auth_application_service
|
||||
.admin_promote_external_to_internal(auth_user.id, target_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok((StatusCode::OK, Json(dto)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registration Control
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user