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
@@ -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