feat(userLifecycle): prepare external service identity

prepare identity service for external users, support of:
        - magic_link (url challenge via email)
        - self issued oidc (eventually social login)
        - open cloud mesh
This commit is contained in:
Edouard Vanbelle
2026-06-01 17:30:56 +02:00
parent 6a6f070106
commit 395e0b6e61
4 changed files with 101 additions and 5 deletions
+1 -4
View File
@@ -124,10 +124,7 @@ These are codified in the module-level docstring of `application/ports/user_life
| `AuthzCacheLifecycleHook` | `src/infrastructure/services/pg_acl_engine.rs` (same module as the Moka cache it invalidates) | `on_user_logout` + `on_user_deleted`: `engine.invalidate_user_groups_cache(user.id())` — drops the cached transitive-group expansion immediately so a re-login (or a re-created account with the same id) sees fresh memberships without waiting for the 30 s TTL. `on_user_created` + `on_user_login`: `Ok(())` (no stale entry could exist for these). |
| `SessionRevocationLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher; no dedicated session service module today) | `on_user_deleted`: explicit `session_storage.revoke_all_user_sessions(user.id())` + aggregate audit event (`event = "user.sessions_revoked_on_delete", count = N`). Replaces the silent FK CASCADE with an observable revocation. All other events: `Ok(())`. |
| `DeletionMode` | enum on the trait | Distinguishes admin-initiated delete (`AdminDelete` — currently identical to GDPR but reserved for a future trash-with-retention policy) from GDPR right-to-erasure purge (`GdprPurge` — for a future sweeper). PR 4 ships the variants; future PRs may add per-mode behaviour. |
Subsequent PRs add:
- `ExternalIdentityLifecycleHook` (PR 5, stub for now) — populated by the upcoming magic-link external-user feature.
| `ExternalIdentityLifecycleHook` *(no-op stub)* | `src/application/services/external_identity_service.rs` (own module — the future home of the magic-link / OIDC / OCM provenance service) | All four methods are explicit `Ok(())` today. The magic-link PR sequence will populate them: `on_user_created` will INSERT into the future `auth.user_external_identity` side-table for `is_external` users; `on_user_login` will bump `last_verified_at` for GDPR-sweeper purposes; `on_user_logout` and `on_user_deleted` will stay no-ops (FK CASCADE handles row removal). The stub lands now so the magic-link PR fills in hook bodies without touching DI registration. |
### How the delete transaction composes
@@ -0,0 +1,86 @@
//! External-identity service.
//!
//! Houses the lifecycle hook for grant-only external users — recipients
//! authenticating via magic-link, OIDC-only, or OCM federation rather than
//! a local password. Today the module ships only a **stubbed
//! `ExternalIdentityLifecycleHook`**: it's registered on the dispatcher
//! so the slot exists in DI, but every method is an explicit `Ok(())`
//! no-op. The magic-link PR sequence will fill in the bodies.
//!
//! # What the populated hook will do (forward reference)
//!
//! A future `auth.user_external_identity` side-table will store provenance
//! per external user:
//!
//! ```text
//! user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE
//! source TEXT NOT NULL CHECK (source IN ('magic_link','oidc','ocm'))
//! issuer TEXT -- OIDC iss URL or OCM partner FQDN
//! external_sub TEXT -- OIDC sub or OCM remote user id; NULL for magic_link
//! last_verified_at TIMESTAMPTZ NOT NULL DEFAULT now()
//! UNIQUE (source, issuer, external_sub)
//! ```
//!
//! Then this hook will:
//!
//! | Event | Action |
//! |-------------------|--------|
//! | `on_user_created` | If `user.is_external()`, INSERT a row into `auth.user_external_identity` with the source/issuer/sub captured from the create flow (magic-link bootstrap, OIDC JIT, OCM federation). |
//! | `on_user_login` | If `user.is_external()`, `UPDATE … SET last_verified_at = NOW()` for the user's provenance row. Used by the GDPR sweeper to identify "external users we haven't heard from in 13 months". |
//! | `on_user_logout` | `Ok(())` — provenance is connection-level, not session-level. |
//! | `on_user_deleted` | `Ok(())` — the FK CASCADE on `user_external_identity.user_id` handles row removal. |
//!
//! Today (PR 5): all four methods return `Ok(())` so the dispatcher
//! exercises the registration path without any side effect.
use async_trait::async_trait;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// **Stubbed for now.** Populates the future `auth.user_external_identity`
/// side-table when the magic-link / external-user flow ships. Registered
/// on the dispatcher today as a no-op so the magic-link PR doesn't need to
/// touch DI — it only fills in the hook body.
///
/// All four `UserLifecycleHook` methods are explicit `Ok(())` per the
/// "no defaults — every event acknowledged" convention.
pub struct ExternalIdentityLifecycleHook;
#[async_trait]
impl UserLifecycleHook for ExternalIdentityLifecycleHook {
fn name(&self) -> &'static str {
"external_identity"
}
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
// STUB: magic-link / OIDC JIT / OCM bootstrap PR will INSERT the
// provenance row here when `user.is_external()`.
Ok(())
}
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
// STUB: magic-link PR will UPDATE `last_verified_at` here so the
// GDPR sweeper can identify dormant external users.
Ok(())
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
// Provenance is connection-level, not session-level — no work
// to do on logout even in the populated future version.
Ok(())
}
async fn on_user_deleted(
&self,
_user: &User,
_mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
// FK CASCADE on `auth.user_external_identity.user_id` will
// handle row removal automatically — no work needed here even
// in the populated future version.
Ok(())
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod blob_lifecycle_service;
pub mod calendar_service;
pub mod contact_service;
pub mod device_auth_service;
pub mod external_identity_service;
pub mod favorites_service;
pub mod file_lifecycle_service;
pub mod file_management_service;
+13 -1
View File
@@ -711,7 +711,16 @@ impl AppServiceFactory {
// delete (with audit) —
// replaces the silent FK
// CASCADE.
// PR 5 will append ExternalIdentityLifecycleHook (stub).
// 5. ExternalIdentityLifecycleHook — STUB. No-op for every
// event today; the
// magic-link / OIDC-only /
// OCM PR will fill it in
// to populate
// `auth.user_external_identity`.
// Last in the chain so it
// observes the latest user
// state before the chain
// commits.
let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone()));
let user_lifecycle = Arc::new(
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
@@ -732,6 +741,9 @@ impl AppServiceFactory {
crate::application::services::user_lifecycle_service::SessionRevocationLifecycleHook::new(
session_repo_for_hook,
),
))
.with_hook(Arc::new(
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook,
)),
);