refactor(oidc): prep. support of Open Cloud Mesh

add federation kind (OCM, OIDC, MagicLink)
    rename oidc_provider into federation_issuer
    rename oidc_subject into federation_subject
This commit is contained in:
Edouard Vanbelle
2026-08-08 15:10:23 +02:00
parent 521040521d
commit 10a8dd7d8b
13 changed files with 1254 additions and 127 deletions
@@ -110,8 +110,9 @@ mod tests {
"alice@example.com".to_string(),
Some("alice".to_string()),
None,
None,
None,
None, // federation_kind
None, // federation_issuer
None, // federation_subject
UserRole::User,
0,
false,
@@ -152,8 +153,9 @@ mod tests {
"bob@example.com".to_string(),
None,
None,
None,
None,
None, // federation_kind
None, // federation_issuer
None, // federation_subject
UserRole::User,
0,
false,
+3 -3
View File
@@ -131,7 +131,7 @@ pub struct AdminUserSummaryDto {
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
/// alongside `federation_issuer` 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
@@ -171,7 +171,7 @@ impl From<UserListEntry> for AdminUserSummaryDto {
storage_used_bytes: entry.storage_used_bytes,
last_login_at: entry.last_login_at,
active: entry.active,
auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()),
auth_provider: entry.federation_issuer.unwrap_or_else(|| "local".to_string()),
is_external: entry.is_external,
has_password: entry.has_password,
opaque_registered: entry.opaque_registered,
@@ -208,7 +208,7 @@ impl From<User> for UserDto {
last_login_at: p.last_login_at,
active: p.active,
// Some(provider) moves the String; None still allocates "local".
auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()),
auth_provider: p.federation_issuer.unwrap_or_else(|| "local".to_string()),
image: p.image,
can_edit_image,
is_external: p.is_external,
+12 -8
View File
@@ -194,10 +194,14 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Changes a user's password
async fn change_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DomainError>;
/// Finds a user by OIDC provider + subject pair
async fn get_user_by_oidc_subject(
/// Finds a user by federation (issuer, subject) pair. Historically
/// called for OIDC lookups (the only federation kind in-tree at rename
/// time); after Phase B/C of the federation-identity rename the
/// caller passes the true `iss` URL rather than a display label. See
/// `docs/plan/ocm.md § Schema rename` for the transition.
async fn get_user_by_federation_subject(
&self,
provider: &str,
issuer: &str,
subject: &str,
) -> Result<User, DomainError>;
@@ -383,12 +387,12 @@ pub trait SessionStoragePort: Send + Sync + 'static {
/// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`:
/// revoke every session belonging to the user identified by
/// `(oidc_provider, oidc_subject)`. Returns the affected user id, or
/// `None` if we don't know that user.
async fn revoke_user_sessions_by_oidc_subject(
/// `(federation_issuer, federation_subject)`. Returns the affected
/// user id, or `None` if we don't know that user.
async fn revoke_user_sessions_by_federation_subject(
&self,
oidc_provider: &str,
oidc_subject: &str,
issuer: &str,
subject: &str,
) -> Result<Option<Uuid>, DomainError>;
}
@@ -560,8 +560,9 @@ impl AuthApplicationService {
dto.email.clone(),
dto.username.clone(),
password_hash,
None,
None,
None, // federation_kind: local password registration
None, // federation_issuer
None, // federation_subject
role,
quota,
false,
@@ -662,8 +663,9 @@ impl AuthApplicationService {
email,
Some(username.clone()),
Some(password_hash),
None,
None,
None, // federation_kind: setup admin is local
None, // federation_issuer
None, // federation_subject
role,
quota,
false,
@@ -1523,7 +1525,7 @@ impl AuthApplicationService {
.await?
} else if let Some(sub) = claims.sub.as_ref() {
self.session_storage
.revoke_user_sessions_by_oidc_subject(&provider_name, sub)
.revoke_user_sessions_by_federation_subject(&provider_name, sub)
.await?
.into_iter()
.collect()
@@ -2810,8 +2812,9 @@ impl AuthApplicationService {
email,
Some(dto.username.clone()),
Some(password_hash),
None,
None,
None, // federation_kind: admin-created external, no federation link yet
None, // federation_issuer
None, // federation_subject
UserRole::User,
0,
true,
@@ -2821,8 +2824,9 @@ impl AuthApplicationService {
email,
Some(dto.username.clone()),
Some(password_hash),
None,
None,
None, // federation_kind: admin-created local user
None, // federation_issuer
None, // federation_subject
role,
quota,
false,
@@ -3395,7 +3399,7 @@ impl AuthApplicationService {
// 5. Look up existing user by OIDC subject
let user = match self
.user_storage
.get_user_by_oidc_subject(&provider_name, &claims.sub)
.get_user_by_federation_subject(&provider_name, &claims.sub)
.await
{
Ok(mut existing_user) => {
@@ -3511,6 +3515,14 @@ impl AuthApplicationService {
oidc_email,
Some(username.clone()),
None,
Some(crate::domain::entities::user::FederationKind::Oidc),
// TODO Phase B: `provider_name` still carries the
// OXICLOUD_OIDC_PROVIDER_NAME display label instead
// of the true `iss` URL. Lazy-rebind on subsequent
// logins converts the row (see docs/plan/ocm.md
// § Rename PR — Phase B). First-login value is the
// label for backwards compatibility with existing
// rows.
Some(provider_name.clone()),
Some(claims.sub.clone()),
role,
@@ -225,12 +225,18 @@ impl MagicLinkInviteService {
// External users are created without a username or password.
// `password_hash IS NULL` is the canonical no-password marker.
//
// federation_kind stays None in Phase A of the federation-identity
// rename — magic-link externals get their `federation_kind` stamp
// in a future PR when the invite handler is refactored to opt into
// the composable federation model. Behaviour is unchanged today.
let mut user = User::new(
normalised_email.to_string(),
None,
None,
None,
None,
None, // federation_kind
None, // federation_issuer
None, // federation_subject
UserRole::User,
0,
true,
@@ -1006,15 +1012,21 @@ mod tests {
use crate::domain::entities::user::{User, UserRole};
fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User {
let (provider, subject) = match oidc {
Some((p, s)) => (Some(p.to_string()), Some(s.to_string())),
None => (None, None),
use crate::domain::entities::user::FederationKind;
let (kind, issuer, subject) = match oidc {
Some((p, s)) => (
Some(FederationKind::Oidc),
Some(p.to_string()),
Some(s.to_string()),
),
None => (None, None, None),
};
User::new(
"test@example.com".to_string(),
None,
password.map(str::to_string),
provider,
kind,
issuer,
subject,
UserRole::User,
0,
+121 -34
View File
@@ -28,6 +28,62 @@ impl std::fmt::Display for UserRole {
}
}
/// The trust chain that owns a user's identity. NULL on `auth.users`
/// means "pure local user, no external federation" — the common case for
/// password/OPAQUE accounts.
///
/// See `docs/plan/ocm.md § Identity & auth model` for the full model
/// including the `(federation_kind, federation_issuer, federation_subject)`
/// composite identity key.
///
/// - `Oidc`: authenticated via an OIDC provider; `federation_issuer` =
/// the id_token `iss` claim, `federation_subject` = the `sub` claim.
/// Note: legacy rows may still hold the OXICLOUD_OIDC_PROVIDER_NAME
/// display label as `federation_issuer` until Phase B of the rename
/// backfills them to real issuer URLs.
/// - `Ocm`: OCM 1.1 federated principal (future — `docs/plan/ocm.md`).
/// `federation_issuer` = peer domain, `federation_subject` = federated
/// address (e.g. `alice@remote.example.com`).
/// - `MagicLink`: external invitee whose only auth is mailbox
/// possession. Both `federation_issuer` and `federation_subject`
/// remain NULL for this kind — the identity is the local `email`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FederationKind {
MagicLink,
Ocm,
Oidc,
}
impl FederationKind {
/// Canonical DB / wire spelling — matches the CHECK constraint on
/// `auth.users.federation_kind`.
pub fn as_str(self) -> &'static str {
match self {
FederationKind::MagicLink => "magic_link",
FederationKind::Ocm => "ocm",
FederationKind::Oidc => "oidc",
}
}
/// Parse the DB / wire spelling. Returns `None` for anything other
/// than the three canonical values — the CHECK constraint on the
/// column and the enum variants are the source of truth.
pub fn parse(s: &str) -> Option<Self> {
match s {
"magic_link" => Some(FederationKind::MagicLink),
"ocm" => Some(FederationKind::Ocm),
"oidc" => Some(FederationKind::Oidc),
_ => None,
}
}
}
impl std::fmt::Display for FederationKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Authorization-relevant account flags, fetched without the heavyweight
/// profile columns. The full user row drags `image` along — a data URI of
/// up to 512 KiB — which per-request guards (`require_internal_user`,
@@ -72,8 +128,12 @@ pub struct User {
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
/// Which trust chain minted the (issuer, subject) pair. `None` on
/// pure local users (password/OPAQUE). See [`FederationKind`] for the
/// three federation flavours.
federation_kind: Option<FederationKind>,
federation_issuer: Option<String>,
federation_subject: Option<String>,
image: Option<String>,
/// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM
/// federated). FALSE = storage-owning internal user. Hooks that
@@ -161,8 +221,9 @@ pub struct UserParts {
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub oidc_provider: Option<String>,
pub oidc_subject: Option<String>,
pub federation_kind: Option<FederationKind>,
pub federation_issuer: Option<String>,
pub federation_subject: Option<String>,
pub image: Option<String>,
pub is_external: bool,
pub given_name: Option<String>,
@@ -190,8 +251,9 @@ impl User {
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
federation_kind,
federation_issuer,
federation_subject,
image,
is_external,
given_name,
@@ -213,8 +275,9 @@ impl User {
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
federation_kind,
federation_issuer,
federation_subject,
image,
is_external,
given_name,
@@ -230,7 +293,7 @@ impl User {
///
/// One unified constructor for every kind of user (internal, OIDC-linked,
/// external). The credential slots and the `is_external` marker are all
/// caller-controlled — what makes a user "OIDC" is `oidc_subject =
/// caller-controlled — what makes a user "OIDC" is `federation_subject =
/// Some(_)`, what makes them "external" is `is_external = true`. There
/// are no hidden sentinel values; an absent credential is `None`.
///
@@ -239,7 +302,7 @@ impl User {
/// * `username` — optional handle (2-64 chars, no `@`)
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
/// the user has no password yet (magic-link or OIDC bootstrap)
/// * `oidc_provider`, `oidc_subject` — both `Some` when the user is
/// * `federation_issuer`, `federation_subject` — both `Some` when the user is
/// linked to an external IdP, both `None` otherwise
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
/// `users_external_not_admin` DB CHECK constraint)
@@ -251,8 +314,9 @@ impl User {
email: String,
username: Option<String>,
password_hash: Option<String>,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
federation_kind: Option<FederationKind>,
federation_issuer: Option<String>,
federation_subject: Option<String>,
role: UserRole,
storage_quota_bytes: i64,
is_external: bool,
@@ -280,12 +344,26 @@ impl User {
"External users must have storage_quota_bytes = 0".to_string(),
));
}
// OIDC linkage is all-or-nothing: both provider and subject set,
// or neither. The DB has a UNIQUE index on (provider, subject)
// WHERE both non-NULL; partial state would corrupt that.
if oidc_provider.is_some() != oidc_subject.is_some() {
// Federation linkage is all-or-nothing: both issuer and subject set,
// or neither. The DB has a UNIQUE index on
// (federation_kind, federation_issuer, federation_subject) WHERE
// federation_kind IS NOT NULL; partial state would corrupt that.
//
// For kinds that don't carry an authority-issued subject
// (MagicLink today — identity is the local email), both fields
// stay None even when `federation_kind` is set. The check below
// only fires on inconsistent partial state.
if federation_issuer.is_some() != federation_subject.is_some() {
return Err(UserError::ValidationError(
"oidc_provider and oidc_subject must both be set or both be None".to_string(),
"federation_issuer and federation_subject must both be set or both be None".to_string(),
));
}
// If either field is set, federation_kind MUST also be set — the
// schema keys anti-duplicate on the composite (kind, issuer,
// subject) and a NULL kind would defeat the uniqueness.
if federation_issuer.is_some() && federation_kind.is_none() {
return Err(UserError::ValidationError(
"federation_kind is required when federation_issuer/subject are set".to_string(),
));
}
@@ -302,8 +380,9 @@ impl User {
updated_at: now,
last_login_at: None,
active: true,
oidc_provider,
oidc_subject,
federation_kind,
federation_issuer,
federation_subject,
image: None,
is_external,
given_name: None,
@@ -355,8 +434,9 @@ impl User {
updated_at,
last_login_at,
active,
oidc_provider: None,
oidc_subject: None,
federation_kind: None,
federation_issuer: None,
federation_subject: None,
image: None,
// `from_data` is the minimal-args reconstruction path used by
// tests and JWT-claim-based principal hydration (which doesn't
@@ -387,8 +467,9 @@ impl User {
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
federation_kind: Option<FederationKind>,
federation_issuer: Option<String>,
federation_subject: Option<String>,
image: Option<String>,
is_external: bool,
given_name: Option<String>,
@@ -413,8 +494,9 @@ impl User {
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
federation_kind,
federation_issuer,
federation_subject,
image,
is_external,
given_name,
@@ -556,12 +638,16 @@ impl User {
format!("{}…", &self.id.to_string()[..8])
}
pub fn oidc_provider(&self) -> Option<&str> {
self.oidc_provider.as_deref()
pub fn federation_kind(&self) -> Option<FederationKind> {
self.federation_kind
}
pub fn oidc_subject(&self) -> Option<&str> {
self.oidc_subject.as_deref()
pub fn federation_issuer(&self) -> Option<&str> {
self.federation_issuer.as_deref()
}
pub fn federation_subject(&self) -> Option<&str> {
self.federation_subject.as_deref()
}
pub fn image(&self) -> Option<&str> {
@@ -739,7 +825,7 @@ impl User {
/// Returns true if this is an OIDC-only user (no password)
pub fn is_oidc_user(&self) -> bool {
self.oidc_provider.is_some()
self.federation_issuer.is_some()
}
/// Returns true iff this user has any non-magic-link authentication
@@ -748,7 +834,7 @@ impl User {
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
/// flag widens the policy at the service layer (`magic_link_eligibility`).
pub fn has_login_credential(&self) -> bool {
self.password_hash.is_some() || self.oidc_subject.is_some()
self.password_hash.is_some() || self.federation_subject.is_some()
}
/// Set the password hash. The new password must be hashed externally
@@ -891,9 +977,10 @@ mod tests {
Utc::now(),
None,
true,
None,
None,
None,
None, // federation_kind
None, // federation_issuer
None, // federation_subject
None, // image
false,
given.map(str::to_string),
family.map(str::to_string),
@@ -81,16 +81,16 @@ pub trait SessionRepository: Send + Sync + 'static {
async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult<Vec<Uuid>>;
/// Revokes every session belonging to the user identified by
/// `(oidc_provider, oidc_subject)`.
/// `(federation_issuer, federation_subject)`.
///
/// Fallback path for the Back-Channel Logout handler when the IdP
/// omits `sid` from the logout_token — coarser than sid-based
/// revocation (kills the user's other devices too). Returns the
/// user id of the affected account, or `None` if no matching user.
async fn revoke_user_sessions_by_oidc_subject(
async fn revoke_user_sessions_by_federation_subject(
&self,
oidc_provider: &str,
oidc_subject: &str,
issuer: &str,
subject: &str,
) -> SessionRepositoryResult<Option<Uuid>>;
/// Deletes expired sessions
+5 -5
View File
@@ -45,7 +45,7 @@ pub struct UserListEntry {
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub oidc_provider: Option<String>,
pub federation_issuer: Option<String>,
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).
@@ -53,7 +53,7 @@ pub struct UserListEntry {
/// 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
/// federation_issuer 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
@@ -173,10 +173,10 @@ pub trait UserRepository: Send + Sync + 'static {
/// Deletes a user
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
/// Finds a user by OIDC provider + subject pair
async fn get_user_by_oidc_subject(
/// Finds a user by federation (issuer, subject) pair.
async fn get_user_by_federation_subject(
&self,
provider: &str,
issuer: &str,
subject: &str,
) -> UserRepositoryResult<User>;
@@ -390,12 +390,17 @@ impl SessionRepository for SessionPgRepository {
/// Back-Channel Logout fallback — the IdP omitted `sid` in the
/// logout_token, so we revoke every session belonging to the user
/// identified by (oidc_provider, oidc_subject). Users are looked up
/// through the existing auth.users columns.
async fn revoke_user_sessions_by_oidc_subject(
/// identified by (federation_issuer, federation_subject). Users are
/// looked up through auth.users; the query is federation-kind-agnostic
/// today (matches any user regardless of federation_kind) — an OCM
/// notification arriving here would find only OIDC users because that's
/// the only path that writes federation_issuer today, but the schema
/// admits OCM rows too, so this method may see multi-kind matches once
/// OCM lands. Restrict by federation_kind then if that becomes ambiguous.
async fn revoke_user_sessions_by_federation_subject(
&self,
oidc_provider: &str,
oidc_subject: &str,
issuer: &str,
subject: &str,
) -> SessionRepositoryResult<Option<Uuid>> {
// Two-step: look up the user first (deterministic error class if
// the user is unknown), then revoke. Combining into a single
@@ -404,11 +409,11 @@ impl SessionRepository for SessionPgRepository {
let user_row = sqlx::query(
r#"
SELECT id FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
WHERE federation_issuer = $1 AND federation_subject = $2
"#,
)
.bind(oidc_provider)
.bind(oidc_subject)
.bind(issuer)
.bind(subject)
.fetch_optional(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
@@ -432,9 +437,9 @@ impl SessionRepository for SessionPgRepository {
tracing::info!(
target: "audit",
event = "oidc.backchannel_logout_by_sub",
oidc_provider = %oidc_provider,
oidc_subject = %oidc_subject,
event = "federation.backchannel_logout_by_sub",
federation_issuer = %issuer,
federation_subject = %subject,
user_id = %user_id,
revoked_count = result.rows_affected(),
"👮🏻‍♂️ OIDC backchannel-logout revoked all user sessions by sub"
@@ -586,12 +591,12 @@ impl SessionStoragePort for SessionPgRepository {
.map_err(DomainError::from)
}
async fn revoke_user_sessions_by_oidc_subject(
async fn revoke_user_sessions_by_federation_subject(
&self,
oidc_provider: &str,
oidc_subject: &str,
issuer: &str,
subject: &str,
) -> Result<Option<Uuid>, DomainError> {
SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject)
SessionRepository::revoke_user_sessions_by_federation_subject(self, issuer, subject)
.await
.map_err(DomainError::from)
}
@@ -282,12 +282,13 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject,
image, is_external,
given_name, family_name, email_verified_at,
preferred_locale, notify_on_share, ui_preferences
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)
RETURNING *
"#,
@@ -303,8 +304,9 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.updated_at())
.bind(user_clone.last_login_at())
.bind(user_clone.is_active())
.bind(user_clone.oidc_provider())
.bind(user_clone.oidc_subject())
.bind(user_clone.federation_kind().map(|k| k.as_str()))
.bind(user_clone.federation_issuer())
.bind(user_clone.federation_subject())
.bind(user_clone.image())
.bind(user_clone.is_external())
.bind(user_clone.given_name())
@@ -339,7 +341,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -370,8 +372,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -391,7 +396,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -422,8 +427,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -443,7 +451,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -474,8 +482,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -512,7 +523,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, is_external,
federation_kind, federation_issuer, federation_subject, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE id = ANY($1)
@@ -544,8 +555,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
None, // image — not projected (notification-recipient path)
row.get("is_external"),
row.get("given_name"),
@@ -693,7 +707,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -731,8 +745,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -780,13 +797,13 @@ impl UserRepository for UserPgRepository {
// 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
// federation_issuer, 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,
last_login_at, active, federation_issuer, 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
@@ -815,7 +832,7 @@ impl UserRepository for UserPgRepository {
storage_used_bytes,
last_login_at,
active,
oidc_provider,
federation_issuer,
is_external,
has_password,
opaque_registered,
@@ -833,7 +850,7 @@ impl UserRepository for UserPgRepository {
storage_used_bytes,
last_login_at,
active,
oidc_provider,
federation_issuer,
is_external,
has_password,
opaque_registered,
@@ -856,7 +873,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -894,8 +911,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -999,7 +1019,7 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
@@ -1034,8 +1054,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -1068,7 +1091,7 @@ impl UserRepository for UserPgRepository {
}
/// Finds a user by OIDC provider + subject pair
async fn get_user_by_oidc_subject(
async fn get_user_by_federation_subject(
&self,
provider: &str,
subject: &str,
@@ -1079,11 +1102,11 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
WHERE federation_issuer = $1 AND federation_subject = $2
"#,
)
.bind(provider)
@@ -1110,8 +1133,11 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
@@ -1367,12 +1393,12 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn get_user_by_oidc_subject(
async fn get_user_by_federation_subject(
&self,
provider: &str,
subject: &str,
) -> Result<User, DomainError> {
UserRepository::get_user_by_oidc_subject(self, provider, subject)
UserRepository::get_user_by_federation_subject(self, provider, subject)
.await
.map_err(DomainError::from)
}
@@ -1441,7 +1467,7 @@ mod integration_tests {
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, is_external
federation_issuer, is_external
) VALUES (
$1, $2, $3, NULL, $4::auth.userrole,
$5, 0,
@@ -1515,7 +1541,7 @@ mod integration_tests {
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
assert_eq!(page[1].username, None);
assert!(page[1].is_external);
assert_eq!(page[1].oidc_provider.as_deref(), Some("integration-idp"));
assert_eq!(page[1].federation_issuer.as_deref(), Some("integration-idp"));
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
.await