From 84a1b0e005164baf3c5c63eae1346e46fde2860d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 5 Aug 2026 21:12:51 +0200 Subject: [PATCH] feat(admin/user): show users auth method + add cli to recover broken opaque login --- Cargo.lock | 84 ++++++ Cargo.toml | 17 +- Dockerfile | 14 +- frontend/src/lib/api/types.ts | 7 + .../src/routes/admin/[[tab]]/+page.svelte | 112 +++++-- src/application/dtos/user_dto.rs | 12 + src/bin/opaque-hurl-helper.rs | 4 +- src/bin/opaque-setup.rs | 34 --- src/bin/oxicloud-cli.rs | 282 ++++++++++++++++++ src/domain/repositories/user_repository.rs | 9 + .../repositories/pg/user_pg_repository.rs | 19 +- src/infrastructure/services/opaque_service.rs | 7 +- .../api/handlers/opaque_auth_handler.rs | 2 +- src/interfaces/middleware/user.rs | 5 +- 14 files changed, 527 insertions(+), 81 deletions(-) delete mode 100644 src/bin/opaque-setup.rs create mode 100644 src/bin/oxicloud-cli.rs diff --git a/Cargo.lock b/Cargo.lock index b3a354d7..db5d4d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,12 +124,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -1386,6 +1430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1394,8 +1439,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1434,6 +1493,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compression-codecs" version = "0.4.38" @@ -3509,6 +3574,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "iso6709parse" version = "0.1.2" @@ -4266,6 +4337,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oneshot" version = "0.1.13" @@ -4377,6 +4454,7 @@ dependencies = [ "blake3", "bytes", "chrono", + "clap", "criterion", "dashmap", "dotenvy", @@ -7155,6 +7233,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utoipa" version = "5.5.0" diff --git a/Cargo.toml b/Cargo.toml index ec017fcd..a1cf19fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,10 @@ flate2 = "1.1.9" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } chrono = { version = "0.4.45", features = ["serde"] } +# CLI argument parser for the operator toolbox (`oxicloud-cli`). `derive` +# for the subcommand tree; the runtime dep is small (< 300 KiB compiled) +# and shared with any future subcommand additions. +clap = { version = "4", features = ["derive"] } # RFC 5545 iCalendar parser + emitter. # # Adopted 2026-07-14 to replace the hand-rolled property-scan in @@ -175,12 +179,13 @@ name = "migrate-nfc-filenames" path = "src/bin/migrate-nfc-filenames.rs" [[bin]] -name = "opaque-setup" -path = "src/bin/opaque-setup.rs" -# One-shot operator helper — prints a base64 OPAQUE ServerSetup for -# OXICLOUD_OPAQUE_SERVER_SETUP. Runs once per deployment; the output -# must be persisted verbatim (rotating invalidates every user's -# registration — see docs/config/authentication.md §OPAQUE). +name = "oxicloud-cli" +path = "src/bin/oxicloud-cli.rs" +# Operator toolbox — subcommand-driven CLI for tasks that don't belong +# in the main server. Currently: `oxicloud-cli opaque {setup,reset}`. +# Ships in the release Dockerfile as the single operator-facing helper +# (replaces the earlier per-task `opaque-setup` bin, which was folded +# into `oxicloud-cli opaque setup`). [[bin]] name = "opaque-hurl-helper" diff --git a/Dockerfile b/Dockerfile index aaab1317..0f86c094 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,9 +44,9 @@ RUN mkdir -p src/bin && \ echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ echo 'fn main() {}' > src/bin/generate-openapi.rs && \ echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \ - echo 'fn main() {}' > src/bin/opaque-setup.rs && \ + echo 'fn main() {}' > src/bin/oxicloud-cli.rs && \ echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup && \ + cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli && \ rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* # ─── Stage 3: Build the application ────────────────────────────────────────── @@ -86,7 +86,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \ GITHUB_SHA="${GITHUB_SHA}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup + cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli # The SPA is built by the Vite frontend stage; bring it in for the runtime copy # below (build.rs has no asset pipeline — it only injects git metadata). COPY --from=frontend /static-dist ./static-dist @@ -128,7 +128,7 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin mkdir -p /app/bin && \ cp target/release/oxicloud /app/bin/oxicloud && \ cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames && \ - cp target/release/opaque-setup /app/bin/opaque-setup + cp target/release/oxicloud-cli /app/bin/oxicloud-cli # ─── Stage 3c: Select the builder & normalise the binary path ───────────────── # FROM expands the global ${BUILDER} arg to alias the chosen builder stage @@ -140,7 +140,7 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin FROM ${BUILDER} AS app ARG BIN_DIR RUN mkdir -p /app/release && \ - cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/opaque-setup" /app/release/ + cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/oxicloud-cli" /app/release/ # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── FROM alpine:3.24.0 @@ -174,10 +174,10 @@ COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/ # Ship the OPAQUE server-setup generator alongside the server so operators # can generate their `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` value inside the # container without a separate Rust toolchain: -# docker run --rm opaque-setup # prints the base64 value +# docker run --rm oxicloud-cli opaque setup # prints the base64 value # One-shot, side-effect-free — safe to include; the runtime doesn't # invoke it, admins do (see docs/config/authentication.md §OPAQUE). -COPY --from=app --chmod=755 /app/release/opaque-setup /usr/local/bin/ +COPY --from=app --chmod=755 /app/release/oxicloud-cli /usr/local/bin/ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ chmod 755 /usr/local/bin/entrypoint.sh diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 1e9c738a..50833641 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -252,6 +252,13 @@ export type AdminUserSummary = Pick< | 'auth_provider' | 'is_external' > & { + /** TRUE = user has a server-verifiable password on file (legacy or + * admin-set). Combined with `opaque_registered` and `auth_provider`, + * the admin table derives the full auth capability set — a user with + * `has_password=false`, `opaque_registered=false` AND + * `auth_provider === 'local'` is passwordless (magic-link only, + * which is the default for externals). */ + has_password?: boolean; /** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration * succeeded, or the user completed a manual re-registration). */ opaque_registered?: boolean; diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 6805dfd8..79b704a3 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -2663,29 +2663,44 @@ narrow; the full provider stays reachable via the tooltip on the badge. --> + {#if isOidcUser(u)} {u.auth_provider} - {:else} - {t('admin.local', 'local')} {/if} - + {#if u.has_password} + + + {t('admin.auth_password', 'password')} + + {/if} {#if u.opaque_migrated} {t('admin.opaque_envelope', 'envelope')} {/if} + {#if !isOidcUser(u) && !u.has_password && !u.opaque_registered} + + + + {t('admin.auth_mail', 'mail')} + + {/if} @@ -4211,6 +4247,38 @@ * matches the way `.badge--active` vs `.badge--inactive` split * "success" from "neutral". */ + /* + * `password` chip — neutral tone since the presence of a password + * is neither notably positive nor risky on its own; the OPAQUE + * chip next to it (if present) carries the "hardened" signal. + */ + .badge--password { + background: var(--color-bg-muted); + color: var(--color-text-muted); + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + + /* + * `mail` chip — user has no password / no OPAQUE / no SSO, so + * their only login path is a magic-link to their mailbox. + * Warning tone because for an internal account it's usually an + * in-flight-invitation state the operator wants to notice; for + * externals (grant-only) it's the by-design default. Named + * `mail` (not `passwordless`) so the label describes the actual + * channel the operator has to care about. + */ + .badge--mail { + background: var(--color-warning-bg, var(--color-bg-muted)); + color: var(--color-warning-text, var(--color-text-muted)); + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .badge--opaque { background: var(--color-success-bg); color: var(--color-success-text); @@ -4278,8 +4346,14 @@ splits mid-word either. */ .role-badges { display: flex; - flex-flow: row nowrap; - align-items: center; + /* Stack role above the (rare) external tag so the two signals + * are unambiguous — one reads "admin\nexternal" instead of a + * horizontal row that could be misread as "admin external" + * (as-if externals-can-be-admin, which they can't). Narrow + * items align left so the badges keep a consistent left edge + * with the surrounding text. */ + flex-flow: column nowrap; + align-items: flex-start; gap: var(--space-1, 0.25rem); } diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 47e61e9e..2d44d92f 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -115,6 +115,17 @@ pub struct AdminUserSummaryDto { pub active: bool, pub auth_provider: String, pub is_external: bool, + /// TRUE when the user has a server-verifiable password on file + /// (`password_hash IS NOT NULL`). The admin table uses this + /// alongside `oidc_provider` and `opaque_registered` to render + /// the user's full capability set: a `password` chip lights up + /// here, an OIDC provider name renders the SSO badge, an + /// envelope-on-file flips the OPAQUE chip. A user with none of + /// the three is passwordless (magic-link only — the SPA renders + /// a distinct `passwordless` chip in that case). Admin-only + /// exposure — see the DTO doc for why this isn't on `UserDto`. + #[serde(default)] + pub has_password: bool, /// Mirrors `UserListEntry::opaque_registered` — TRUE when the user /// has an OPAQUE envelope on file. Surfaced on the admin table so /// operators can see per-user rollout progress during the @@ -148,6 +159,7 @@ impl From for AdminUserSummaryDto { active: entry.active, auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()), is_external: entry.is_external, + has_password: entry.has_password, opaque_registered: entry.opaque_registered, opaque_migrated: entry.opaque_migrated, } diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index 77f8f3f8..697e70aa 100644 --- a/src/bin/opaque-hurl-helper.rs +++ b/src/bin/opaque-hurl-helper.rs @@ -45,9 +45,7 @@ //! because subsequent hurl files don't assume `hasOpaque=false`. use base64::Engine as _; -use base64::engine::general_purpose::{ - STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD, -}; +use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD}; /// Decode base64 emitted by the server. The server emits URL-safe-no-pad /// (matching what the SPA's WASM client expects); this helper accepts diff --git a/src/bin/opaque-setup.rs b/src/bin/opaque-setup.rs deleted file mode 100644 index 0c4e99e4..00000000 --- a/src/bin/opaque-setup.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `opaque-setup` — one-shot operator helper that mints a fresh -//! [`opaque_ke::ServerSetup`] and prints its base64 encoding to stdout. -//! -//! The output goes into `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` (env var or secrets -//! manager) and MUST be persisted verbatim. Rotating it invalidates every -//! user's registration — treat it like the JWT secret, only more so. -//! -//! Usage: -//! ```text -//! cargo run --bin opaque-setup > opaque_setup.b64 -//! # or paste directly into your env / .env file: -//! echo "OXICLOUD_AUTH_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)" >> .env -//! ``` -//! -//! The generated value is a small (~64 byte) Ristretto255 keypair -//! serialised for storage. Nothing else — no config file, no key -//! rotation state. Idempotent per invocation (each run generates a -//! DIFFERENT value; only run it once per deployment). - -use oxicloud::infrastructure::services::opaque_service::OpaqueService; - -fn main() { - let b64 = OpaqueService::generate_server_setup_b64(); - // Print JUST the value — no trailing newline commentary — so shell - // pipelines (`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)`) - // capture cleanly without needing `tr -d '\n'` afterwards. - println!("{b64}"); - // Guidance goes to stderr so it doesn't contaminate the pipeline. - eprintln!(); - eprintln!("=== OPAQUE server setup generated. ==="); - eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP."); - eprintln!("NEVER rotate: rotating invalidates every user's registration."); - eprintln!("Treat this value like your JWT secret."); -} diff --git a/src/bin/oxicloud-cli.rs b/src/bin/oxicloud-cli.rs new file mode 100644 index 00000000..66bb71ae --- /dev/null +++ b/src/bin/oxicloud-cli.rs @@ -0,0 +1,282 @@ +//! `oxicloud-cli` — operator toolbox for the OxiCloud deployment. +//! +//! Single binary with subcommand tree, shipped alongside the `oxicloud` +//! server binary. Replaces the per-task one-off bins (previously +//! `opaque-setup`, and any future `opaque-reset` etc.) with a +//! discoverable `--help`-driven surface so the container ships one +//! toolbox binary rather than N one-off ones. +//! +//! ## Layout +//! +//! ```text +//! oxicloud-cli [flags] +//! +//! Domains: +//! opaque OPAQUE aPAKE substrate management +//! setup Print a fresh ServerSetup value for OXICLOUD_AUTH_OPAQUE_SERVER_SETUP +//! reset Clear envelope(s) so silent-migration re-mints under current KSF +//! ``` +//! +//! Growth pattern: each new domain gets its own module below (e.g. +//! `mod opaque`) with a `#[derive(Subcommand)]` enum for its actions +//! and a `run(args) -> ExitCode` entrypoint. Keep each module +//! self-contained so a future extraction is a file move. +//! +//! ## Environment +//! +//! * `DATABASE_URL` — required by any subcommand that talks to the DB +//! (`opaque reset`); not needed for pure primitive helpers +//! (`opaque setup`). Each subcommand documents its own dependencies. + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "oxicloud-cli", + version, + about = "OxiCloud operator toolbox", + long_about = "OxiCloud operator toolbox — subcommand entrypoint for operational \ + tasks that don't belong in the main server binary." +)] +struct Cli { + #[command(subcommand)] + domain: Domain, +} + +#[derive(Subcommand)] +enum Domain { + /// OPAQUE aPAKE substrate management (setup, reset). + Opaque { + #[command(subcommand)] + action: opaque::Action, + }, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.domain { + Domain::Opaque { action } => opaque::run(action).await, + } +} + +// ── opaque domain ────────────────────────────────────────────────────── + +mod opaque { + use std::env; + use std::process::ExitCode; + + use clap::Subcommand; + use oxicloud::infrastructure::services::opaque_service::OpaqueService; + use sqlx::{PgPool, Row}; + + #[derive(Subcommand)] + pub enum Action { + /// Generate a fresh OPAQUE ServerSetup and print its base64 + /// encoding to stdout. Guidance goes to stderr so shell + /// pipelines capture cleanly. + /// + /// Run ONCE per deployment; persist the printed value as + /// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value + /// invalidates every user's OPAQUE registration — treat it + /// like your JWT secret. + Setup, + + /// Clear the OPAQUE envelope for one user or all users + /// WITHOUT touching password or setting force_password_change. + /// + /// Use case: KSF rotation. If you change + /// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes + /// become cryptographically incompatible with the newly + /// published KSF — logins fail with InvalidCredentials. + /// Nulling the envelope columns forces the SPA's `/lookup` + /// to report `hasOpaque: false`, which routes the next login + /// through legacy `/api/auth/login`; silent-migration then + /// mints a fresh envelope under the CURRENT KSF. Passwords + /// are unchanged. + /// + /// NOT for forgotten-passphrase recovery — use the admin + /// password-reset endpoint (`PUT /api/admin/users/{id}/password`) + /// which sets a temp password + force_change flag in one shot. + Reset { + /// Email OR username to reset (dispatched on `@` presence, + /// same rule as `POST /api/auth/login`). + #[arg(long, conflicts_with = "all")] + user: Option, + + /// Reset every user with an OPAQUE envelope. + #[arg(long, conflicts_with = "user")] + all: bool, + + /// Print what would change without touching the DB. + #[arg(long)] + dry_run: bool, + }, + } + + pub async fn run(action: Action) -> ExitCode { + match action { + Action::Setup => run_setup(), + Action::Reset { + user, + all, + dry_run, + } => run_reset(user, all, dry_run).await, + } + } + + fn run_setup() -> ExitCode { + // Match the legacy `opaque-setup` bin's contract: + // - value on stdout, no trailing commentary (pipeline-safe) + // - guidance on stderr + let b64 = OpaqueService::generate_server_setup_b64(); + println!("{b64}"); + eprintln!(); + eprintln!("=== OPAQUE server setup generated. ==="); + eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP."); + eprintln!("NEVER rotate: rotating invalidates every user's registration."); + eprintln!("Treat this value like your JWT secret."); + ExitCode::from(0) + } + + async fn run_reset(user: Option, all: bool, dry_run: bool) -> ExitCode { + // clap enforces `conflicts_with`, but not "at least one of". + // Belt-and-braces check here so the failure is explicit. + if user.is_none() && !all { + eprintln!("opaque reset: pass either --user or --all"); + return ExitCode::from(2); + } + + let database_url = match env::var("DATABASE_URL") { + Ok(v) => v, + Err(_) => { + eprintln!("opaque reset: DATABASE_URL not set"); + return ExitCode::from(2); + } + }; + let pool = match PgPool::connect(&database_url).await { + Ok(p) => p, + Err(e) => { + eprintln!("opaque reset: failed to connect to database: {e}"); + return ExitCode::from(1); + } + }; + + // Preview the affected row set before writing. Doubles as + // dry-run output and as diagnostics when --user matches nothing. + // Envelope-presence bool lets the operator see which rows had + // an envelope vs which only carry a stale migration mark. + let select_sql = if all { + r#" + SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope + FROM auth.users + WHERE opaque_envelope IS NOT NULL + OR opaque_migrated_at IS NOT NULL + ORDER BY email + "# + } else { + r#" + SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope + FROM auth.users + WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END + "# + }; + let rows_result = if all { + sqlx::query(select_sql).fetch_all(&pool).await + } else { + let ident = user.as_deref().unwrap(); + sqlx::query(select_sql).bind(ident).fetch_all(&pool).await + }; + let rows = match rows_result { + Ok(r) => r, + Err(e) => { + eprintln!("opaque reset: query failed: {e}"); + return ExitCode::from(1); + } + }; + if rows.is_empty() { + if all { + println!("opaque reset: no users have an OPAQUE envelope — nothing to do."); + return ExitCode::from(0); + } else { + eprintln!( + "opaque reset: no user matches --user {} — nothing changed.", + user.as_deref().unwrap_or("") + ); + return ExitCode::from(1); + } + } + + println!( + "opaque reset ({}): {} row(s) to affect", + if dry_run { + "DRY RUN — no writes" + } else { + "EXECUTING" + }, + rows.len() + ); + for row in &rows { + let id: uuid::Uuid = row.get("id"); + let email: String = row.get("email"); + let had_envelope: bool = row.get("had_envelope"); + println!( + " {} {} {}", + id, + email, + if had_envelope { + "had-envelope" + } else { + "no-envelope-had-migrated-mark" + } + ); + } + if dry_run { + return ExitCode::from(0); + } + + // Actual UPDATE. Kept identical in shape to the SELECT above so + // the planner sees the same query pattern for both. We + // DELIBERATELY do NOT touch password_hash or + // force_password_change_at_next_login — this tool is scoped + // to "the passwords are fine, the envelopes are stale." + let update_sql_all = r#" + UPDATE auth.users + SET opaque_envelope = NULL, + opaque_ciphersuite_version = NULL, + opaque_registered_at = NULL, + opaque_migrated_at = NULL + WHERE opaque_envelope IS NOT NULL + OR opaque_migrated_at IS NOT NULL + "#; + let update_sql_one = r#" + UPDATE auth.users + SET opaque_envelope = NULL, + opaque_ciphersuite_version = NULL, + opaque_registered_at = NULL, + opaque_migrated_at = NULL + WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END + "#; + let write_result = if all { + sqlx::query(update_sql_all).execute(&pool).await + } else { + let ident = user.as_deref().unwrap(); + sqlx::query(update_sql_one).bind(ident).execute(&pool).await + }; + let affected = match write_result { + Ok(r) => r.rows_affected(), + Err(e) => { + eprintln!("opaque reset: update failed: {e}"); + return ExitCode::from(1); + } + }; + println!( + "opaque reset: cleared envelope columns on {affected} row(s). \ + Users log in with their existing password; silent-migration \ + re-mints envelopes under the current KSF on next login." + ); + ExitCode::from(0) + } +} diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 84786133..0ff4f659 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -47,6 +47,15 @@ pub struct UserListEntry { pub active: bool, pub oidc_provider: Option, pub is_external: bool, + /// TRUE when `auth.users.password_hash IS NOT NULL` — user has a + /// server-verifiable password on file (legacy or admin-set). + /// Distinct from `opaque_registered` (which is the zero-knowledge + /// envelope): a fully-migrated user carries BOTH — password for + /// the fallback / operator flows, envelope for the actual login. + /// A user with `has_password = false AND !opaque_registered AND + /// oidc_provider IS NULL` is passwordless — the only path in is + /// via magic-link (or, for externals, whatever grant they hold). + pub has_password: bool, /// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user /// has completed OPAQUE registration (typically via the Phase 2 /// silent-migration hook after a successful legacy login). Surfaced diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index d8b4fa79..132b4c21 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -769,18 +769,25 @@ impl UserRepository for UserPgRepository { bool, bool, bool, + bool, ), >( - // OPAQUE columns are projected as booleans via `IS NOT NULL` - // rather than as timestamps so the row-mapping tuple stays - // small and the wire shape is exactly what the admin table - // needs. Both are per-row scalar tests — no cost beyond the - // full-table sequential scan the LIMIT/OFFSET already pays. + // Auth-credential columns projected as booleans via `IS NOT + // NULL` rather than as timestamps / hashes so the row-mapping + // tuple stays small and the wire shape is exactly what the + // admin table needs. Per-row scalar tests — no cost beyond + // the full-table sequential scan the LIMIT/OFFSET already + // pays. `has_password` on the password_hash column tells + // the admin table whether a server-verifiable password is + // on file; combined with the two OPAQUE flags and + // oidc_provider, the SPA derives the full "capability + // set" per user (password / OPAQUE / SSO / passwordless). r#" SELECT id, username, email, role::text, storage_quota_bytes, storage_used_bytes, last_login_at, active, oidc_provider, is_external, + (password_hash IS NOT NULL) AS has_password, (opaque_envelope IS NOT NULL) AS opaque_registered, (opaque_migrated_at IS NOT NULL) AS opaque_migrated FROM auth.users @@ -810,6 +817,7 @@ impl UserRepository for UserPgRepository { active, oidc_provider, is_external, + has_password, opaque_registered, opaque_migrated, )| UserListEntry { @@ -827,6 +835,7 @@ impl UserRepository for UserPgRepository { active, oidc_provider, is_external, + has_password, opaque_registered, opaque_migrated, }, diff --git a/src/infrastructure/services/opaque_service.rs b/src/infrastructure/services/opaque_service.rs index e15f1bf7..3be4e3f6 100644 --- a/src/infrastructure/services/opaque_service.rs +++ b/src/infrastructure/services/opaque_service.rs @@ -92,7 +92,7 @@ impl OpaqueService { ErrorKind::InternalError, "opaque", "OXICLOUD_AUTH_OPAQUE_SERVER_SETUP is required when OPAQUE is enabled — \ - generate one with `oxicloud opaque-setup` and persist it in the env", + generate one with `oxicloud-cli opaque setup` and persist it in the env", ) })?; let setup = decode_server_setup(setup_b64)?; @@ -256,7 +256,10 @@ mod tests { }; let err = OpaqueService::from_config(cfg).expect_err("must reject missing setup"); assert_eq!(err.kind, ErrorKind::InternalError); - assert!(err.to_string().contains("OXICLOUD_AUTH_OPAQUE_SERVER_SETUP")); + assert!( + err.to_string() + .contains("OXICLOUD_AUTH_OPAQUE_SERVER_SETUP") + ); } #[test] diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index d0da3065..37ccdac3 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -105,9 +105,9 @@ use uuid::Uuid; use crate::application::dtos::user_dto::AuthResponseDto; use crate::common::di::AppState; -use crate::interfaces::api::cookie_auth; use crate::infrastructure::services::opaque_login_exchange::{ExchangeId, OpaqueLoginExchange}; use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite}; +use crate::interfaces::api::cookie_auth; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUserId; diff --git a/src/interfaces/middleware/user.rs b/src/interfaces/middleware/user.rs index 7c351b64..1d442184 100644 --- a/src/interfaces/middleware/user.rs +++ b/src/interfaces/middleware/user.rs @@ -255,10 +255,7 @@ pub async fn require_internal_user_layer( /// on a rate-limited public path that doesn't carry a `CurrentUser` at /// middleware time; the gate never fires on it. If refresh ever moves /// under the gate, add `(&Method::POST, "/api/auth/refresh")` here. -fn is_password_change_pending_allowlisted( - method: &axum::http::Method, - path: &str, -) -> bool { +fn is_password_change_pending_allowlisted(method: &axum::http::Method, path: &str) -> bool { use axum::http::Method; matches!( (method, path),