feat(account): upgrade external to internal

This commit is contained in:
Edouard Vanbelle
2026-07-14 04:12:48 +02:00
parent 5ae551a93d
commit f331dbf0ee
14 changed files with 957 additions and 3 deletions
+17
View File
@@ -229,6 +229,23 @@ pub struct RefreshTokenDto {
pub refresh_token: String,
}
/// Body for `POST /api/auth/upgrade-to-internal`. Converts an
/// authenticated external user into an internal user with their own
/// personal drive.
///
/// `password` is optional — semantics decided per deployment:
/// * If `magic_link` is in `OXICLOUD_AUTH_METHODS` (and OIDC isn't
/// enabled) → password can be omitted; user remains magic-link-only
/// for login after upgrade.
/// * Otherwise → password is required; refusal returns 400
/// `error_type = "PasswordRequired"`. Without it the upgraded user
/// would have no login path.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpgradeToInternalDto {
#[serde(default)]
pub password: Option<String>,
}
/// Authenticated current user data (for use in application services)
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CurrentUser {
+28
View File
@@ -193,4 +193,32 @@ pub trait UserLifecycleHook: Send + Sync {
mode: DeletionMode,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError>;
/// Fires after `AuthApplicationService::upgrade_to_internal`
/// successfully persists `is_external = false` on the user row —
/// the external → internal conversion path. The `user` argument
/// reflects the POST-upgrade state (`is_external() == false`,
/// `storage_quota_bytes > 0`, `password_hash` maybe stamped).
///
/// Load-bearing implementations:
/// * `PersonalDriveLifecycleHook` → provisions the home drive
/// (would have short-circuited on `on_user_created` because
/// the user was external at creation).
/// * `AuditLifecycleHook` → emits `event="auth.user_upgraded"`.
///
/// Default: no-op. Hooks that don't care about upgrade don't need
/// to opt in — this keeps the trait extension backwards-compatible
/// with existing implementations. Do NOT reuse `on_user_created`
/// for this event: hooks that observe `last_login_at().is_none()`
/// as "first ever" or that clean up magic-link tokens
/// (`ExternalIdentityLifecycleHook`) would mis-fire.
///
/// Idempotency: fires exactly once per successful upgrade transition
/// (guarded by `is_external` toggling). A retried upgrade after a
/// crash would hit the `AlreadyInternal` guard in the service and
/// this hook wouldn't fire again — so hooks may assume "first
/// upgrade" semantics.
async fn on_upgraded_to_internal(&self, _user: &User) -> Result<(), DomainError> {
Ok(())
}
}
@@ -1,5 +1,6 @@
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto,
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto,
UpgradeToInternalDto, UserDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
@@ -1233,6 +1234,147 @@ impl AuthApplicationService {
Ok(revoked_count)
}
/// External → internal account upgrade.
///
/// Contract:
/// * Caller must be authenticated as the user being upgraded.
/// Session-elevation is not required — being logged in as
/// yourself IS the proof of intent.
/// * User must be `is_external = true` — else the entity refuses
/// with `UserError::AlreadyInternal`, surfaced as `error_type =
/// "AlreadyInternal"` (409).
/// * OIDC-linked users are refused (the IdP owns their identity).
/// * If `dto.password` is `None`, the deployment MUST have magic-
/// link login enabled — otherwise the upgraded user would have
/// no login path. Refused with `error_type = "PasswordRequired"`
/// (400) in that case.
/// * Domain-allowlist check lives at the HANDLER layer, mirroring
/// the register handler — the service doesn't hold that config.
///
/// On success:
/// * User's `is_external` flipped to `false`.
/// * `password_hash` set from the provided password (Argon2id) or
/// left as-is (magic-link-only upgrade).
/// * `storage_quota_bytes` set to the default user quota (capped
/// by disk).
/// * `PersonalDriveLifecycleHook::on_upgraded_to_internal` runs and
/// provisions the home drive + root folder + owner grant via the
/// atomic CTE. Failure at this step is logged but the row update
/// stands — the next login's `on_user_login` safety-net retries
/// provisioning.
/// * `user_flags_cache` invalidated eagerly so per-request guards
/// (WebDAV / CalDAV / CardDAV) observe the new `is_external`
/// within cache-round-trip time, not the 30-second TTL.
/// * Audit log emits `event="user.upgraded_to_internal"` via the
/// `AuditLifecycleHook` on the dispatched event.
pub async fn upgrade_to_internal(
&self,
caller_id: Uuid,
dto: UpgradeToInternalDto,
) -> Result<UserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// Precondition: caller is currently external. Fast-path 409 so
// the audit log carries a clear reason before the entity's own
// guard fires.
if !user.is_external() {
tracing::info!(
target: "audit",
event = "user.upgrade_rejected",
reason = "already_internal",
user_id = %user.id(),
username = %user.display_for_audit(),
"👮🏻‍♂️ upgrade refused: user is already internal",
);
return Err(DomainError::new(
ErrorKind::Conflict,
"User",
"Account is already internal",
));
}
// OIDC-linked: never. The IdP owns identity and role.
if user.is_oidc_user() {
tracing::info!(
target: "audit",
event = "user.upgrade_rejected",
reason = "oidc_user",
user_id = %user.id(),
"👮🏻‍♂️ upgrade 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",
));
}
// Password policy composite:
// * Provided → validate + hash.
// * Omitted → only accepted when magic-link login is on
// for this deployment (otherwise no login path post-upgrade).
let password_hash = match dto.password.as_deref() {
Some(pw) if !pw.is_empty() => {
if pw.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long",
));
}
Some(self.password_hasher.hash_password(pw).await?)
}
_ => {
if !self.is_magic_link_login_allowed() {
tracing::info!(
target: "audit",
event = "user.upgrade_rejected",
reason = "password_required",
user_id = %user.id(),
"👮🏻‍♂️ upgrade refused: password omitted but magic-link login is not available on this deployment",
);
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password is required — magic-link login is not enabled on this deployment",
));
}
None
}
};
// Quota policy: same as a fresh regular-user signup.
let quota = self.capped_quota(&UserRole::User);
user.promote_to_internal(password_hash, quota)
.map_err(|e| {
// The entity refuses `AlreadyInternal` here belt-and-braces
// against a race with a concurrent upgrade; the pre-check
// above already covers the intended path.
DomainError::new(
ErrorKind::Conflict,
"User",
format!("Upgrade refused: {}", e),
)
})?;
let updated = self.user_storage.update_user(user).await?;
// Invalidate the flags cache so subsequent per-request guards
// observe the new `is_external=false` without waiting for the
// 30-second TTL. Same pattern as `change_user_role`.
self.user_flags_cache.invalidate(&caller_id);
// Dispatch — home-drive provisioning happens here. Log-and-
// continue: a provisioning failure leaves the row updated and
// the next login's safety-net (`on_user_login`) retries.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_upgraded_to_internal(&updated).await;
}
Ok(UserDto::from(updated))
}
pub async fn change_password(
&self,
user_id: Uuid,
@@ -962,6 +962,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook {
self.provision_if_needed(user).await
}
/// External → internal upgrade. `on_user_created` fired at signup
/// with `is_external=true` and short-circuited in
/// `provision_if_needed`. The user is now internal — same helper
/// runs, but this time the `is_external` guard passes through and
/// the atomic CTE creates their default drive + root folder +
/// owner grant. Idempotent by construction: a rerun after a partial
/// failure hits the `find_default_for_user` short-circuit.
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
self.provision_if_needed(user).await
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
// Drives don't react to logout. Explicit no-op per the
// "no defaults" convention.
@@ -62,6 +62,28 @@ impl UserLifecycleService {
}
}
/// Upgraded: log-and-continue. Called by
/// `AuthApplicationService::upgrade_to_internal` after the
/// `is_external = false` UPDATE persists. Same log-and-continue
/// semantics as `dispatch_created` — the row is already updated,
/// hook failure at (e.g.) home-drive provisioning is recoverable
/// on the next login via `PersonalDriveLifecycleHook::on_user_login`
/// (its safety-net path already handles the "user is internal but
/// no drive yet" case idempotently).
pub async fn dispatch_upgraded_to_internal(&self, user: &User) {
for h in &self.hooks {
if let Err(e) = h.on_upgraded_to_internal(user).await {
tracing::error!(
target: "user_lifecycle",
hook = h.name(),
user_id = %user.id(),
error = %e,
"on_upgraded_to_internal failed; drive provisioning will retry on next login"
);
}
}
}
/// Login: log-and-continue. Same reasoning as `dispatch_created`.
/// Must fire BEFORE `user.register_login()` so that hooks observing
/// `last_login_at().is_none()` correctly detect the first-ever login.
@@ -199,6 +221,19 @@ impl UserLifecycleHook for AuditLifecycleHook {
);
Ok(())
}
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
// Post-upgrade state — `is_external` is already `false` here
// (the service persisted before dispatching), so we don't log
// it as a field; the event name carries the transition.
tracing::info!(
target: "audit",
event = "user.upgraded_to_internal",
user_id = %user.id(),
username = %user.display_for_audit(),
);
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────────────────
+4
View File
@@ -79,6 +79,9 @@ pub enum UserError {
ValidationError(String),
/// Authentication error
AuthenticationError(String),
/// Upgrade path: the user is already internal — cannot re-upgrade.
/// Surfaced by the service as `error_type = "AlreadyInternal"`.
AlreadyInternal,
}
impl Display for UserError {
@@ -88,6 +91,7 @@ impl Display for UserError {
UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg),
UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg),
UserError::AlreadyInternal => write!(f, "User is already an internal account"),
}
}
}
+41
View File
@@ -512,6 +512,47 @@ impl User {
}
}
/// Promote a currently-external user to an internal account.
/// Atomically flips the invariant-linked fields:
/// * `is_external` → false
/// * `password_hash` → provided (Some) or preserved (None)
/// * `storage_quota_bytes` → quota (external users had 0; DB CHECK
/// `users_external_no_storage` enforces the pair before this call
/// and would refuse a non-zero quota on an external row — the
/// write MUST flip `is_external` first, which happens
/// transactionally at persist time via the sqlx UPDATE).
///
/// Password is `Option<String>` because the service allows password-
/// less upgrades when magic-link login is available on the
/// deployment. When `None`, `password_hash` stays as it was (either
/// NULL, or a hash left over from an admin-created invitation —
/// externals don't authenticate with it either way).
///
/// Refuses if the caller is already internal — the upgrade path
/// only makes sense on `is_external = true` users. Service pre-
/// checks `user.is_external()` before calling; this guard is
/// belt-and-braces against a race.
///
/// Admin combo is impossible by construction: external + admin was
/// refused at creation (see `User::new`), so a promoted external
/// user always retains their `UserRole::User` — role isn't changed.
pub fn promote_to_internal(
&mut self,
password_hash: Option<String>,
storage_quota_bytes: i64,
) -> UserResult<()> {
if !self.is_external {
return Err(UserError::AlreadyInternal);
}
self.is_external = false;
if let Some(hash) = password_hash {
self.password_hash = Some(hash);
}
self.storage_quota_bytes = storage_quota_bytes;
self.updated_at = Utc::now();
Ok(())
}
pub fn set_image(&mut self, image: Option<String>) {
self.image = image;
self.updated_at = Utc::now();
@@ -472,7 +472,19 @@ impl UserRepository for UserPgRepository {
family_name = $13,
email_verified_at = $14,
preferred_locale = $15,
notify_on_share = $16
notify_on_share = $16,
-- Include `is_external` so the external →
-- internal upgrade path
-- (`AuthApplicationService::upgrade_to_internal`)
-- can flip this flag. Previously omitted
-- because no code path mutated it after
-- creation. The DB CHECK
-- `users_external_no_storage`
-- (`is_external=false OR quota=0`) is
-- satisfied by the upgrade because it
-- writes both fields in the same UPDATE:
-- `is_external=false, quota>0`.
is_external = $17
WHERE id = $1
"#,
)
@@ -492,6 +504,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
.bind(user_clone.is_external())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
+115 -1
View File
@@ -12,7 +12,8 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto,
UserDto,
};
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState;
@@ -45,6 +46,7 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
.route("/me/image", put(update_user_image))
.route("/me/profile", patch(update_profile))
.route("/change-password", put(change_password))
.route("/upgrade-to-internal", post(upgrade_to_internal))
.route("/logout", post(logout))
}
@@ -639,6 +641,118 @@ pub async fn change_password(
Ok(StatusCode::OK)
}
/// Convert the authenticated external user into a full internal
/// account. The caller must currently be `is_external = true`; on
/// success, `is_external` is flipped to `false`, a personal drive is
/// provisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the
/// user's flags cache is invalidated so subsequent per-request guards
/// see the new state within cache-round-trip time.
///
/// Password policy:
/// * If the deployment offers magic-link login
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not
/// enabled AND SMTP is wired), the body's `password` field is
/// optional — an upgraded user without a password stays magic-
/// link-only for login.
/// * Otherwise, `password` is required — refused with 400
/// `error_type = "PasswordRequired"`.
///
/// Domain gate: the caller's email domain MUST be in
/// `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty).
/// Otherwise invitations would become a bypass of the operator's
/// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`.
///
/// Response: the updated `UserDto` (post-upgrade view — `is_external`
/// is false, `storage_quota_bytes` is set).
#[utoipa::path(
post,
path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto,
responses(
(status = 200, description = "Upgrade succeeded", body = UserDto),
(status = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"),
(status = 409, description = "Already internal"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Domain gate. Mirrors the register handler
// (`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS`). Rationale: an
// internal-user invitation must NOT become a way around the
// operator's self-registration policy. If a domain isn't
// allowlisted for register, it shouldn't be allowed for upgrade
// either. External users on non-allowlisted domains remain
// external — they can still act on shared resources but never own
// a drive of their own on this deployment.
let allow_list = &state.core.config.auth.registration_allowed_email_domains;
if !allow_list.is_empty() {
// The service re-fetches the user inside `upgrade_to_internal`;
// one extra id-lookup here just to extract the email is cheap
// and keeps the domain check at the same layer as the register
// handler for consistency.
let email = auth_service
.auth_application_service
.get_user_by_id(user_id)
.await
.map(|dto| dto.email)?;
let domain = email
.split('@')
.nth(1)
.map(|d| d.trim().to_ascii_lowercase())
.unwrap_or_default();
if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) {
tracing::info!(
target: "audit",
event = "user.upgrade_rejected",
reason = "domain_not_allowed",
user_id = %user_id,
domain = %domain,
"👮🏻‍♂️ upgrade refused: email domain not in \
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS"
);
return Err(AppError::new(
StatusCode::FORBIDDEN,
"This deployment does not accept new accounts from your email domain.",
"RegistrationDomainNotAllowed",
));
}
}
let updated = auth_service
.auth_application_service
.upgrade_to_internal(user_id, dto)
.await
.map_err(|err| match err.message.as_str() {
"Account is already internal" => {
AppError::new(StatusCode::CONFLICT, err.message.clone(), "AlreadyInternal")
}
"SSO/OIDC accounts are managed by your identity provider" => {
AppError::new(StatusCode::FORBIDDEN, err.message.clone(), "ManagedByIdP")
}
m if m.starts_with("Password is required") => AppError::new(
StatusCode::BAD_REQUEST,
err.message.clone(),
"PasswordRequired",
),
_ => AppError::from(err),
})?;
Ok((StatusCode::OK, Json(updated)))
}
/// Update the caller's profile (PR 24).
///
/// Fields are individually optional — absent = no change. Username is