From 395e0b6e61a9b989b2eb06f13d29746f5022b370 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 1 Jun 2026 17:30:56 +0200 Subject: [PATCH] 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 --- docs/architecture/user-lifecycle.md | 5 +- .../services/external_identity_service.rs | 86 +++++++++++++++++++ src/application/services/mod.rs | 1 + src/common/di.rs | 14 ++- 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/application/services/external_identity_service.rs diff --git a/docs/architecture/user-lifecycle.md b/docs/architecture/user-lifecycle.md index e1c22876..f34b4a4b 100644 --- a/docs/architecture/user-lifecycle.md +++ b/docs/architecture/user-lifecycle.md @@ -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 diff --git a/src/application/services/external_identity_service.rs b/src/application/services/external_identity_service.rs new file mode 100644 index 00000000..ac76d28b --- /dev/null +++ b/src/application/services/external_identity_service.rs @@ -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(()) + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 6bb1246e..2ca0e9bd 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -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; diff --git a/src/common/di.rs b/src/common/di.rs index 8426102b..7978d166 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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, )), );