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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user