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
+38
View File
@@ -199,6 +199,44 @@ export async function register(email: string, password?: string, username?: stri
}
}
/**
* Convert the authenticated external user into a full internal account.
* Server flips `is_external` to false, provisions a personal drive via
* the lifecycle hook, and returns the updated `User`.
*
* Password is optional — see backend `UpgradeToInternalDto`:
* * If the deployment offers magic-link login, blank password is
* accepted (user remains magic-link-only after upgrade).
* * Otherwise a password is required — the backend refuses with 400
* `error_type = "PasswordRequired"` and the SPA surfaces the
* server message.
*
* Uses `apiFetch` (unlike register/login) because the caller IS
* authenticated; a 401 here IS a genuine "session expired" and the
* refresh interceptor is the right response.
*/
export async function upgradeToInternal(password?: string): Promise<User> {
const body: Record<string, unknown> = {};
if (password) body.password = password;
const res = await apiFetch('/api/auth/upgrade-to-internal', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const { errorType, message } = await parseErrorBody(res);
throw new ApiError(
res.status,
res.statusText,
'/api/auth/upgrade-to-internal',
errorType,
message
);
}
return (await res.json()) as User;
}
export type MagicLinkResult = 'sent' | 'unavailable';
/**
@@ -13,6 +13,14 @@
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
// External users landing here are the natural audience for the
// "upgrade to a full account" prompt — they don't own a drive of
// their own, this view IS their entry point. Internal users don't
// see the banner even when their /shared-with-me happens to be
// non-empty (they already have a drive; nothing to upgrade).
const showUpgradeBanner = $derived(session.isExternalUser);
let raw = $state<IncomingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
@@ -137,6 +145,27 @@
<svelte:head><title>{t('nav.shared_with_me', 'Shared with me')} · OxiCloud</title></svelte:head>
{#if showUpgradeBanner}
<div class="upgrade-banner" role="region" aria-label={t('upgrade.banner_aria', 'Upgrade prompt')}>
<div class="upgrade-banner__body">
<strong>{t('upgrade.banner_title', 'Get your own storage')}</strong>
<span
>{t(
'upgrade.banner_body',
"You're using a guest account. Upgrade to get a personal drive and start uploading files."
)}</span
>
</div>
<a
class="upgrade-banner__cta"
data-testid="shared-with-me-upgrade-btn"
href={resolve('/upgrade')}
>
{t('upgrade.banner_cta', 'Upgrade')}
</a>
</div>
{/if}
<ResourceList
title={t('nav.shared_with_me', 'Shared with me')}
items={entries}
@@ -160,3 +189,58 @@
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}
<style>
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.upgrade-banner__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--color-text);
}
.upgrade-banner__body strong {
font-weight: var(--weight-semibold);
}
.upgrade-banner__body span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.upgrade-banner__cta {
flex-shrink: 0;
padding: var(--space-2) var(--space-4);
background: var(--color-accent);
color: var(--color-accent-contrast);
text-decoration: none;
font-weight: var(--weight-medium);
border-radius: var(--radius-md);
}
.upgrade-banner__cta:hover {
filter: brightness(0.95);
}
@media (width <= 600px) {
.upgrade-banner {
flex-direction: column;
align-items: stretch;
}
.upgrade-banner__cta {
text-align: center;
}
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<script lang="ts">
// Route-scoped auth styles: same visual language as /login so the
// upgrade page reads as an account action, not a settings mini-form.
import '$lib/styles/ported/auth.css';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { ApiError } from '$lib/api/client';
import { getOidcProviders, upgradeToInternal, type OidcProviders } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
let password = $state('');
let confirm = $state('');
let busy = $state(false);
let error = $state('');
let successHint = $state('');
// Provider info tells us whether magic-link login is available.
// `passwordRequired = !magic_link_login_enabled` — if the deployment
// doesn't offer magic-link login, an upgraded user with no password
// would be stuck (no login path), so the form requires one.
let providers = $state<OidcProviders>({ enabled: false });
const passwordRequired = $derived(providers.magic_link_login_enabled !== true);
const matchState = $derived(confirm.length === 0 ? '' : password === confirm ? 'ok' : 'bad');
onMount(async () => {
// Guard: only external users have a meaningful upgrade path. Non-
// externals bounce straight to /files — no error message needed,
// this URL isn't a discoverable page for internal users.
if (session.user && session.user.is_external !== true) {
await goto(resolve('/files'), { replaceState: true });
return;
}
providers = await getOidcProviders();
});
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
successHint = '';
if (passwordRequired && password.length === 0) {
error = t(
'upgrade.password_required',
'Password is required — this deployment does not offer email-link login.'
);
return;
}
if (password.length > 0 && password !== confirm) {
error = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
if (password.length > 0 && password.length < 8) {
error = t('upgrade.password_too_short', 'Password must be at least 8 characters long.');
return;
}
busy = true;
try {
// Server accepts `password` as optional when magic-link login
// is on. `undefined` means "no password" — user stays magic-
// link-only after the upgrade.
const updated = await upgradeToInternal(password.length > 0 ? password : undefined);
session.setUser(updated);
successHint = t(
'upgrade.success',
'Your account has been upgraded. Redirecting to your files…'
);
// Small pause so the success line is visible, then jump into
// the app on the freshly-provisioned home drive.
setTimeout(() => goto(resolve('/files'), { replaceState: true }), 800);
} catch (err) {
if (err instanceof ApiError && err.errorType === 'AlreadyInternal') {
// Race: another tab or session already upgraded. Treat as
// success and just navigate into the app.
await goto(resolve('/files'), { replaceState: true });
return;
}
if (err instanceof ApiError && err.errorType === 'ManagedByIdP') {
error = t(
'upgrade.oidc_user',
'SSO/OIDC accounts are managed by your identity provider. Upgrade is not available.'
);
} else if (err instanceof ApiError && err.errorType === 'RegistrationDomainNotAllowed') {
error = t(
'upgrade.domain_not_allowed',
'This deployment does not accept new accounts from your email domain. Contact the administrator to enable it.'
);
} else if (err instanceof ApiError && err.errorType === 'PasswordRequired') {
error = t(
'upgrade.password_required',
'Password is required — this deployment does not offer email-link login.'
);
} else {
error = err instanceof Error ? err.message : t('upgrade.error', 'Upgrade failed.');
}
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{t('upgrade.title', 'Upgrade to a full account')}</title>
</svelte:head>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="95 67 320 320" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1 class="auth-title">{t('upgrade.title', 'Upgrade to a full account')}</h1>
<p class="auth-subtitle">
{t(
'upgrade.lede',
'Get your own storage and start uploading files. Your existing shares stay untouched.'
)}
</p>
{#if successHint}
<div class="auth-success" style="display: block" role="status">{successHint}</div>
{/if}
{#if error}
<div class="auth-error" style="display: block" role="alert">{error}</div>
{/if}
<form class="auth-form" data-testid="upgrade-form" onsubmit={onSubmit} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="upgrade-password">
{passwordRequired
? t('auth.password', 'Password')
: t('auth.password_optional', 'Password (optional)')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock">
<input
id="upgrade-password"
class="auth-input"
data-testid="upgrade-password-input"
type="password"
bind:value={password}
autocomplete="new-password"
required={passwordRequired}
disabled={busy}
/>
</div>
</div>
{#if password.length > 0}
<div class="auth-input-group">
<label class="auth-label" for="upgrade-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock">
<input
id="upgrade-confirm"
class="auth-input"
data-testid="upgrade-confirm-input"
type="password"
bind:value={confirm}
autocomplete="new-password"
required
disabled={busy}
/>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok' ? 'auth-match--ok' : 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
{/if}
<button
class="auth-button"
type="submit"
data-testid="upgrade-submit-btn"
disabled={busy}
aria-busy={busy}
>
{busy ? t('upgrade.busy', 'Upgrading…') : t('upgrade.submit', 'Upgrade my account')}
</button>
</form>
<div class="auth-toggle">
<button
class="auth-toggle-link"
data-testid="upgrade-cancel-btn"
type="button"
onclick={() => goto(resolve('/shared-with-me'), { replaceState: true })}
>
{t('upgrade.cancel', 'Not now — back to shared with me')}
</button>
</div>
</div>
</div>
+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
+221
View File
@@ -0,0 +1,221 @@
# =============================================================
# OxiCloud — external → internal account upgrade
# =============================================================
# Covers the `POST /api/auth/upgrade-to-internal` endpoint end-to-end:
# admin-creates an external user, external user logs in, calls upgrade,
# lands on an internal account with a personal drive.
#
# Cross-cutting invariants pinned:
# * `is_external` flip is persisted (not just returned).
# * `PersonalDriveLifecycleHook.on_upgraded_to_internal` runs — a new
# default personal drive appears via `/api/drives`.
# * Idempotency: a second upgrade returns 409 `AlreadyInternal`.
# * Domain gate mirrors register: an off-allowlist email is refused
# with 403 `RegistrationDomainNotAllowed`.
# * `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` is
# `example.com,example.test` in tests/common/server.env.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin login (needed to admin-create users + reach
# the delete endpoint for cleanup at the end).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Admin creates an external user `bob-upgrade` with a
# temp password so this test can log in as him without
# going through the magic-link invitation flow (that
# path is exercised elsewhere in external_users.hurl).
# The temp password is real — admin_create_user hashes
# it even for externals — but bob's `is_external=true`
# means he has no drive yet.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"username": "bob-upgrade",
"email": "bob-upgrade@example.com",
"password": "TempExtPass1!",
"role": "user",
"is_external": true
}
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Bob logs in with the temp password. Baseline: he can
# authenticate. Assert `is_external: true` on the /me
# response so a later /me post-upgrade proves the flip.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "bob-upgrade", "password": "TempExtPass1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
GET {{base_url}}/api/auth/me
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == true
jsonpath "$.storage_quota_bytes" == 0
# ─────────────────────────────────────────────────────────────
# Step 4 — Bob calls upgrade with a NEW password. Response is
# the updated UserDto (is_external=false, quota set).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/upgrade-to-internal
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "password": "NewInternalPass1!" }
HTTP 200
[Asserts]
jsonpath "$.is_external" == false
jsonpath "$.storage_quota_bytes" > 0
# ─────────────────────────────────────────────────────────────
# Step 5 — /me confirms the flip persisted (not just returned).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == false
jsonpath "$.storage_quota_bytes" > 0
# ─────────────────────────────────────────────────────────────
# Step 6 — Bob's NEW password works. Proves the password hash
# was persisted (not just held in memory) and the old
# temp password no longer authenticates. Fetches a
# fresh token so the rest of the test uses a session
# whose JWT claims already reflect the upgrade.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "bob-upgrade", "password": "NewInternalPass1!" }
HTTP 200
[Captures]
bob_token_after: jsonpath "$.access_token"
# Old password no longer works.
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "bob-upgrade", "password": "TempExtPass1!" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 7 — Drive provisioning. Bob's default personal drive
# shows up on /api/drives. Before upgrade externals
# have none; after upgrade the lifecycle hook created
# exactly one via the atomic CTE.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token_after}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$[0].kind" == "personal"
# ─────────────────────────────────────────────────────────────
# Step 8 — Idempotency: a second upgrade returns 409
# `AlreadyInternal`. The service pre-checks
# `is_external`; the entity's `promote_to_internal`
# has a matching guard.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/upgrade-to-internal
Authorization: Bearer {{bob_token_after}}
Content-Type: application/json
{ "password": "AnotherPass1!" }
HTTP 409
[Asserts]
jsonpath "$.error_type" == "AlreadyInternal"
# ─────────────────────────────────────────────────────────────
# Step 9 — Domain gate. Create an external user on a domain
# OUTSIDE the allowlist, log in, attempt upgrade, get
# 403 `RegistrationDomainNotAllowed`. Rationale
# documented in the handler: invitations must not
# become a bypass of the operator's registration
# policy.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"username": "carol-offdomain",
"email": "carol@offdomain.invalid",
"password": "TempExtPass1!",
"role": "user",
"is_external": true
}
HTTP 201
[Captures]
carol_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "carol-offdomain", "password": "TempExtPass1!" }
HTTP 200
[Captures]
carol_token: jsonpath "$.access_token"
POST {{base_url}}/api/auth/upgrade-to-internal
Authorization: Bearer {{carol_token}}
Content-Type: application/json
{ "password": "NewInternalPass1!" }
HTTP 403
[Asserts]
jsonpath "$.error_type" == "RegistrationDomainNotAllowed"
# Carol is still external — the refusal didn't half-flip anything.
GET {{base_url}}/api/auth/me
Authorization: Bearer {{carol_token}}
HTTP 200
[Asserts]
jsonpath "$.is_external" == true
# ─────────────────────────────────────────────────────────────
# Cleanup — admin deletes both test users.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *
DELETE {{base_url}}/api/admin/users/{{carol_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *
+1
View File
@@ -149,6 +149,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/user_ui_preferences.hurl" \
"$API_DIR/auth_session_lifecycle.hurl" \
"$API_DIR/auth_magic_link_login.hurl" \
"$API_DIR/auth_upgrade_to_internal.hurl" \
"$API_DIR/registration.hurl" \
"$API_DIR/nc_status_capabilities.hurl" \
"$API_DIR/nc_login_flow_v2.hurl" \