Merge pull request #413 from EdouardVanbelle/chore/user-lifecycle
This commit is contained in:
Generated
+1
@@ -3652,6 +3652,7 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
async-stream = "0.3.6"
|
async-stream = "0.3.6"
|
||||||
|
async-trait = "0.1.83"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "v7", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "v7", "serde"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|||||||
@@ -110,10 +110,12 @@ export default defineConfig({
|
|||||||
{ text: "Resource Listing API", link: "/architecture/resource-listing" },
|
{ text: "Resource Listing API", link: "/architecture/resource-listing" },
|
||||||
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
||||||
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
||||||
|
{ text: "ReBAC Authorization", link: "/architecture/rebac-authorization" },
|
||||||
{ text: "Share Integration", link: "/architecture/share-integration" },
|
{ text: "Share Integration", link: "/architecture/share-integration" },
|
||||||
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
||||||
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||||
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
|
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
|
||||||
|
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ text: "FAQ", link: "/faq" },
|
{ text: "FAQ", link: "/faq" },
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# User Lifecycle Hooks
|
||||||
|
|
||||||
|
Observer pattern for per-service reactions to user state transitions: created, login, logout, deleted. Mirrors the [File and Blob lifecycle](/architecture/file-and-blob-lifecycle) pattern for files; deliberately diverges from it on two points (async + sync await for most events) because user-lifecycle work is rare and sometimes has hard synchronisation requirements.
|
||||||
|
|
||||||
|
## Why hooks
|
||||||
|
|
||||||
|
Before this work, four code paths in `AuthApplicationService` each called `create_personal_folder()` immediately after inserting an `auth.users` row (public registration, first-admin bootstrap, admin-creates-user, OIDC just-in-time provisioning), plus a fifth self-heal at `folder_service.rs` for users whose folder somehow went missing. **Five places, one concern, no shared abstraction.** Adding a future per-user resource — default calendar, address book, GPG keyring, external-identity provenance for the upcoming magic-link feature — would have meant touching all five.
|
||||||
|
|
||||||
|
Hooks fix this once. Each domain service implements `UserLifecycleHook` for the events it cares about; the dispatcher fires events; services that don't care declare explicit `Ok(())` no-ops. New services register a hook in DI and inherit all four events for free.
|
||||||
|
|
||||||
|
## The trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/application/ports/user_lifecycle.rs
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserLifecycleHook: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
async fn on_user_created(&self, user: &User)
|
||||||
|
-> Result<(), DomainError>;
|
||||||
|
|
||||||
|
async fn on_user_login(&self, user: &User)
|
||||||
|
-> Result<(), DomainError>;
|
||||||
|
|
||||||
|
async fn on_user_logout(&self, user: &User, reason: LogoutReason)
|
||||||
|
-> Result<(), DomainError>;
|
||||||
|
|
||||||
|
async fn on_user_deleted(&self, user: &User, mode: DeletionMode)
|
||||||
|
-> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two enums frame the trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum LogoutReason {
|
||||||
|
UserInitiated, // explicit logout
|
||||||
|
SessionExpired, // TTL hit
|
||||||
|
AdminRevoked, // admin-initiated single-session revoke
|
||||||
|
AccountDisabled, // user.active flipped to FALSE → sessions revoked
|
||||||
|
PasswordChanged, // sibling sessions invalidated by password change
|
||||||
|
TokenReused, // session-family reuse detection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum DeletionMode {
|
||||||
|
AdminDelete, // admin deletes via UI; resources go to trash
|
||||||
|
GdprPurge, // GDPR right-to-erasure; hard-delete everything
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**No default impls.** Every implementor must declare all four methods explicitly. Use `Ok(())` for events you don't care about. This forces conscious acknowledgement of every lifecycle event rather than silent inheritance — same convention as `FileLifecycleHook`.
|
||||||
|
|
||||||
|
## Dispatcher semantics
|
||||||
|
|
||||||
|
`UserLifecycleService` aggregates registered hooks and fans out events with **per-event failure semantics**. The trait itself is uniform; the dispatcher decides whether to await, whether to spawn, and whether `Err` aborts.
|
||||||
|
|
||||||
|
| Event | Awaited? | On `Err` |
|
||||||
|
|--------------------|------------------|-----------------------------------------|
|
||||||
|
| `on_user_created` | yes (sync) | log-and-continue (retry on next login) |
|
||||||
|
| `on_user_login` | yes (sync) | log-and-continue (idempotent retry) |
|
||||||
|
| `on_user_logout` | no (spawned) | logged, never propagated |
|
||||||
|
| `on_user_deleted` | yes (sync, in tx) | **abort the transaction** — first `Err` rolls back the user DELETE and propagates to the admin endpoint as a 500 |
|
||||||
|
|
||||||
|
The asymmetry is deliberate. `on_user_created` and `on_user_login` must complete before the session token is returned, so callers see consistent state. `on_user_logout` is bookkeeping; the HTTP response shouldn't wait for cache flushes — the dispatcher spawns. `on_user_deleted` will become atomic-with-the-DELETE in PR 4 when a transaction handle joins the trait signature.
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ AuthApplicationService │
|
||||||
|
│ register / login / logout / delete │
|
||||||
|
└──────────────────────┬───────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ UserLifecycleService::dispatch_* │
|
||||||
|
│ created / login / logout / deleted │
|
||||||
|
└──────────────────────┬───────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
AuditLifecycleHook HomeFolderHook AuthzCacheHook …
|
||||||
|
(PR 1 only) (PR 3) (PR 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Owner-located convention
|
||||||
|
|
||||||
|
Each concrete hook impl lives **next to the service that owns the work**, not in a centralised `lifecycle/` directory.
|
||||||
|
|
||||||
|
Examples (PR plan):
|
||||||
|
|
||||||
|
- `HomeFolderLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-folder policy.
|
||||||
|
- `AuthzCacheLifecycleHook` lives in `src/infrastructure/services/pg_acl_engine.rs` — same module as the Moka cache it invalidates.
|
||||||
|
- `AuditLifecycleHook` lives in `src/application/services/user_lifecycle_service.rs` (with the dispatcher) — cross-cutting, no domain owner.
|
||||||
|
|
||||||
|
This mirrors how `FileLifecycleHook` impls are placed: `ThumbnailRefreshHook` lives in `thumbnail_service.rs`, the audio metadata impl lives in `audio_metadata_service.rs`. A future maintainer reading the folder service sees the lifecycle reactions next to the rest of the folder logic — no jumping between modules to understand why a folder gets created on login.
|
||||||
|
|
||||||
|
## Tips for implementors
|
||||||
|
|
||||||
|
These are codified in the module-level docstring of `application/ports/user_lifecycle.rs` so they show up in IDE hover.
|
||||||
|
|
||||||
|
1. **First-ever login detection.** `on_user_login` fires *before* `user.register_login()` is called, so `user.last_login_at().is_none()` is a reliable "this is the user's first login since account creation" signal. Use it for welcome emails, one-shot default-resource seeding, "complete your profile" prompts.
|
||||||
|
|
||||||
|
2. **External-user short-circuit.** Hooks that provision per-user resources (home folder, default calendar, address book, GPG keys, …) must start with `if user.is_external() { return Ok(()); }`. External users (`is_external = TRUE`) are grant-only recipients — they have no home folder and don't consume storage quota. The DB `CHECK (NOT is_external OR storage_used_bytes = 0)` constraint catches code paths that bypass this short-circuit.
|
||||||
|
|
||||||
|
**Subtle but important rule**: external users can **never** be admins. The DB enforces this via `CHECK (NOT (is_external AND role = 'admin'))`. `User::new_external(...)` doesn't accept a role parameter — it always sets `UserRole::User`. To make an existing external user an admin, an admin must first convert them to internal (`UPDATE auth.users SET is_external = FALSE`) and *then* update the role. The two-step process is intentional friction: granting admin to a federated principal would let external identity providers indirectly manage the local instance.
|
||||||
|
|
||||||
|
3. **Idempotency is mandatory.** `on_user_login` fires on every successful authentication, not just the first. A hook that creates a resource must check whether the resource already exists before creating it. Cache invalidation, audit deduplication, etc., must all tolerate redundant calls.
|
||||||
|
|
||||||
|
4. **External → internal conversion needs no special event.** When an admin flips `is_external = FALSE`, the user's next login fires `on_user_login` with the new flag value. Idempotent hooks see `!is_external` and missing resources → provision. No `on_user_converted` method needed; the safety-net pattern carries the load.
|
||||||
|
|
||||||
|
3. **Failure swallowing on create/login.** If your hook returns `Err`, the user is still created/logged in; only your hook's effect is delayed. Log enough detail via `tracing::error!` that subsequent investigation can identify the user. The next successful login's `on_user_login` will retry idempotently.
|
||||||
|
|
||||||
|
4. **Per-session logout firing.** When a flow revokes multiple sessions in one call (e.g. `revoke_all_user_sessions` on password change), today the dispatcher fires `on_user_logout` ONCE per logical revoke-call. PR 4's `SessionRevocationLifecycleHook` will refine to once-per-session for proper audit granularity. Hooks must accept N redundant calls with the same reason — keep them idempotent.
|
||||||
|
|
||||||
|
5. **`on_user_deleted` runs inside the delete transaction.** The user row still exists when the hook fires; the dispatcher commits only after every hook returns `Ok(())`. Returning `Err` aborts the whole transaction — including the user DELETE itself. Implementors get `tx: &mut sqlx::Transaction<'_, Postgres>` so cleanup queries land in the same tx (e.g. session revocation with audit trail before FK CASCADE wipes the rows). Be conservative about returning `Err`: an abort means the admin's delete operation fails, leaving the user intact.
|
||||||
|
|
||||||
|
6. **Hook order is registration order.** The DI factory determines firing sequence. If two hooks have an ordering dependency (e.g. home-folder must exist before default-calendar can be seeded inside it), the dependent hook registers AFTER the producer. Document the convention inline in the DI block.
|
||||||
|
|
||||||
|
## Concrete hooks shipped today
|
||||||
|
|
||||||
|
| Hook | Lives in | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher) | All four events: emits one `tracing::info!(target: "audit", event = "user.*", ...)` per call, with `is_external` as a field. Co-located because audit is cross-cutting with no domain owner. |
|
||||||
|
| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.folders.user_id` handles the actual row removal. Trash-with-retention is documented as future work. |
|
||||||
|
| `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. |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
```text
|
||||||
|
AuthApplicationService::delete_user_admin(user_id)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
BEGIN
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
dispatch_deleted(user, AdminDelete, &mut tx)
|
||||||
|
│
|
||||||
|
├── AuditLifecycleHook → tracing::info!(event="user.deleted", mode=...)
|
||||||
|
├── HomeFolderLifecycleHook → tracing::info!("home folder will be removed via FK CASCADE")
|
||||||
|
├── AuthzCacheLifecycleHook → engine.invalidate_user_groups_cache(user_id)
|
||||||
|
└── SessionRevocationLifecycleHook
|
||||||
|
→ session_storage.revoke_all_user_sessions(user_id)
|
||||||
|
→ tracing::info!(event="user.sessions_revoked_on_delete",
|
||||||
|
count=N)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DELETE FROM auth.users WHERE id = $1
|
||||||
|
│
|
||||||
|
▼ (FK CASCADE removes folders, files, app_passwords, device_codes, calendar
|
||||||
|
│ shares, address-book shares, etc.; trg_cleanup_grants_user removes the
|
||||||
|
│ user's grants)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
COMMIT
|
||||||
|
```
|
||||||
|
If any hook returns `Err`, the dispatcher propagates it; `delete_user_admin` rolls back the transaction and surfaces the error to the admin endpoint as a 500. The user row, all sessions, all folders/files, all grants — everything stays in place. Hook implementors should keep that strong constraint in mind: returning `Err` from `on_user_deleted` is a heavy hammer.
|
||||||
|
|
||||||
|
### Worked example: brand-new user logs in for the first time
|
||||||
|
|
||||||
|
1. Client POSTs `/api/auth/login` with valid credentials.
|
||||||
|
2. `AuthApplicationService::login()` validates the password against the stored Argon2 hash.
|
||||||
|
3. **Before** `user.register_login()` is called, the dispatcher fires `dispatch_login(&user)`. The user's `last_login_at` is still `None` from creation time.
|
||||||
|
4. `AuditLifecycleHook::on_user_login` runs first (registration order): emits `event = "user.login", user_id = ..., username = ..., is_external = false, first_login = true`.
|
||||||
|
5. `HomeFolderLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `FolderService::ensure_home_folder(uid, username)`. The service checks `list_folders_by_owner(None, uid)` — empty → creates `"My Folder - alice"`. Returns `Ok(true)` (newly created).
|
||||||
|
6. Dispatcher finishes. `user.register_login()` is now called, stamping `last_login_at` to the current time.
|
||||||
|
7. The session row is INSERTed; access + refresh tokens generated; response returned to the client.
|
||||||
|
|
||||||
|
On the user's **second** login: same flow up through step 5, but `ensure_home_folder` finds the existing folder, returns `Ok(false)`, no-op. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time.
|
||||||
|
|
||||||
|
If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders WHERE user_id = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns.
|
||||||
|
|
||||||
|
### State of the art:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DI builds:
|
||||||
|
UserLifecycleService
|
||||||
|
├── AuditLifecycleHook (in user_lifecycle_service.rs)
|
||||||
|
├── HomeFolderLifecycleHook (in folder_service.rs)
|
||||||
|
├── AuthzCacheLifecycleHook (in pg_acl_engine.rs)
|
||||||
|
├── SessionRevocationLifecycleHook (in user_lifecycle_service.rs)
|
||||||
|
└── ExternalIdentityLifecycleHook (in external_identity_service.rs, stubbed)
|
||||||
|
|
||||||
|
AuthApplicationService fires the dispatcher from:
|
||||||
|
├── register() → dispatch_created
|
||||||
|
├── setup_create_admin() → dispatch_created
|
||||||
|
├── admin_create_user() → dispatch_created
|
||||||
|
├── OIDC JIT new-user → dispatch_created + dispatch_login
|
||||||
|
├── login() → dispatch_login (BEFORE register_login)
|
||||||
|
├── OIDC existing-user login → dispatch_login (BEFORE register_login)
|
||||||
|
├── logout() → dispatch_logout(UserInitiated)
|
||||||
|
├── refresh_token reuse → dispatch_logout(TokenReused)
|
||||||
|
├── change_password() → dispatch_logout(PasswordChanged)
|
||||||
|
└── delete_user_admin() → dispatch_deleted(AdminDelete, tx) — abort-on-Err
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future events (NOT shipped — design door)
|
||||||
|
|
||||||
|
These events are reserved for situations that don't exist yet but probably will. Adding a method to the trait costs every hook impl a new no-op forever, so we don't add them speculatively. Each row lists what would force the addition.
|
||||||
|
|
||||||
|
| Future event | Why someone might want it | What would force adding it |
|
||||||
|
|---|---|---|
|
||||||
|
| `on_user_password_changed` | Notify the user via email; invalidate cached credentials; trigger TOTP re-enrolment | A per-user notification service. Today the existing `revoke_all_user_sessions` cascade fires `on_user_logout(PasswordChanged)` for each session — sufficient for current consumers. |
|
||||||
|
| `on_user_role_changed` | Audit promotion to admin; revoke admin-only sessions on demotion | A multi-role system. Today only `admin` / `user` exist and the one-liner audit log at the admin handler covers it. |
|
||||||
|
| `on_user_email_changed` | External users: re-verify the new email via magic-link; notify both old and new addresses | When external users start changing their email. Today email is immutable. |
|
||||||
|
| `on_user_avatar_changed` | Bust thumbnail caches; sync to federated servers (OCM) | When OCM federation ships. |
|
||||||
|
| `on_user_disabled` / `on_user_enabled` | Audit-distinguishable state changes; pause per-user scheduled jobs | When per-user scheduled jobs land. Today `on_user_logout(AccountDisabled)` covers the only consumer. |
|
||||||
|
| `on_user_external_to_internal_converted` | Welcome email; pre-provision internal-only resources at conversion time | If admins routinely promote external users and the next-login lag is unacceptable. Today idempotent `on_user_login` handles conversion fine. |
|
||||||
|
| `on_user_2fa_enabled` / `on_user_2fa_disabled` | Audit; force re-login of other sessions | When 2FA ships. |
|
||||||
|
|
||||||
|
**Rule of thumb for adding any of these**: pair the addition with a default `Ok(())` body (one-time exception to the "no defaults" rule) so existing hooks don't need to declare it. State in the docstring whether the event is await-or-spawn and whether `Err` aborts.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| Concern | Module |
|
||||||
|
|---|---|
|
||||||
|
| Trait + `LogoutReason` + `DeletionMode` enums + tips | `src/application/ports/user_lifecycle.rs` |
|
||||||
|
| Dispatcher + `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` |
|
||||||
|
| Wire-in: created / login / logout / deleted | `src/application/services/auth_application_service.rs` |
|
||||||
|
| DI registration | `src/common/di.rs` (constructs the dispatcher) + `src/infrastructure/auth_factory.rs` (threads it into `AuthApplicationService`) |
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# Plan — `UserLifecycleHook` + `is_external` flag
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Today four code paths in `auth_application_service.rs` each call `create_personal_folder()` immediately after inserting an `auth.users` row: public `register`, `setup_create_admin`, admin `create_user`, and OIDC JIT (lines 283, 360, 832, 1277). A fifth self-heal at `folder_service.rs:350-365` retries home-folder creation when listing root folders returns empty. Five places, one concern, no shared abstraction — and adding a future service (calendar, address book, GPG keyring, external-user provenance for the upcoming magic-link feature) would have to touch all five again.
|
||||||
|
|
||||||
|
Separately, the upcoming "share with `external@example.com`" feature needs `auth.users` to distinguish recipients-with-no-storage from real internal users. The codebase already declares `Subject::External` (`domain/services/authorization.rs`) but no DB representation exists yet.
|
||||||
|
|
||||||
|
This plan introduces a `UserLifecycleHook` trait (mirroring the existing `FileLifecycleHook` / `BlobLifecycleHook` pattern at `application/ports/file_lifecycle.rs`), wires a dispatcher into the four lifecycle events, migrates the scattered eager work into services that own their own lifecycle (each implementing the trait with explicit no-ops for events they don't care about), and adds the `is_external` boolean to `auth.users` so hooks can short-circuit for non-internal users. The change is purely a refactor at first — behaviour is preserved — but it sets up the v2 external-user flow to land as a hook impl rather than a new auth code path.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Trait shape
|
||||||
|
|
||||||
|
The trait diverges from `FileLifecycleHook`'s sync fire-and-forget model on purpose: file events fire on every upload (hot path, fire-and-forget appropriate); user events are rare (login is seconds-per-user, not requests-per-second) and some require synchronous semantics (provisioning must finish before the session token is returned; deletion cleanup must commit atomically with the user DELETE). The trait is async; the dispatcher decides per-event whether errors abort the flow.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/application/ports/user_lifecycle.rs (new)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserLifecycleHook: Send + Sync {
|
||||||
|
/// Short identifier used in tracing / error logs. e.g. "home_folder".
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Fires once after INSERT into auth.users succeeds, regardless of path.
|
||||||
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires after every successful authentication, before the session
|
||||||
|
/// token is returned. MUST be idempotent (safety net for services
|
||||||
|
/// added after the user existed).
|
||||||
|
async fn on_user_login(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires on every session termination. `reason` lets hooks
|
||||||
|
/// distinguish causes (audit cares; cache invalidation does not).
|
||||||
|
async fn on_user_logout(&self, user: &User, reason: LogoutReason)
|
||||||
|
-> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires inside the same transaction as the auth.users DELETE.
|
||||||
|
/// Returning Err aborts the deletion. `mode` distinguishes admin
|
||||||
|
/// delete (policy-driven cleanup) from GDPR purge (force everything).
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LogoutReason {
|
||||||
|
UserInitiated, // explicit logout
|
||||||
|
SessionExpired, // TTL hit
|
||||||
|
AdminRevoked, // single-session revocation by admin
|
||||||
|
AccountDisabled, // user.active flipped to FALSE → all sessions revoked
|
||||||
|
PasswordChanged, // sibling sessions invalidated by a password change
|
||||||
|
TokenReused, // session-family reuse detection (existing feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeletionMode { AdminDelete, GdprPurge }
|
||||||
|
```
|
||||||
|
|
||||||
|
**No default impls.** Every hook must declare all four methods. Use explicit `Ok(())` for events you don't care about — matches the FileLifecycleHook convention and forces conscious acknowledgement.
|
||||||
|
|
||||||
|
### Dispatcher
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/application/services/user_lifecycle_service.rs (new)
|
||||||
|
pub struct UserLifecycleService {
|
||||||
|
hooks: Vec<Arc<dyn UserLifecycleHook>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserLifecycleService {
|
||||||
|
pub fn new() -> Self { Self { hooks: Vec::new() } }
|
||||||
|
pub fn with_hook(mut self, hook: Arc<dyn UserLifecycleHook>) -> Self {
|
||||||
|
self.hooks.push(hook); self
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-event dispatchers with event-specific failure semantics:
|
||||||
|
|
||||||
|
/// Created: log-and-continue. Next login's `on_user_login` retries
|
||||||
|
/// idempotently if anything fails here.
|
||||||
|
pub async fn dispatch_created(&self, user: &User) {
|
||||||
|
for h in &self.hooks {
|
||||||
|
if let Err(e) = h.on_user_created(user).await {
|
||||||
|
tracing::error!(target: "user_lifecycle",
|
||||||
|
hook = h.name(), user_id = %user.id(), error = %e,
|
||||||
|
"on_user_created failed; will retry on next login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login: log-and-continue. Same reasoning.
|
||||||
|
pub async fn dispatch_login(&self, user: &User) { /* same shape */ }
|
||||||
|
|
||||||
|
/// Logout: fire-and-forget (spawned), errors logged. The HTTP
|
||||||
|
/// response shouldn't wait for cache flushes.
|
||||||
|
pub fn dispatch_logout(&self, user: User, reason: LogoutReason) {
|
||||||
|
let hooks = self.hooks.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for h in &hooks {
|
||||||
|
if let Err(e) = h.on_user_logout(&user, reason).await {
|
||||||
|
tracing::error!(target: "user_lifecycle",
|
||||||
|
hook = h.name(), reason = ?reason,
|
||||||
|
user_id = %user.id(), error = %e,
|
||||||
|
"on_user_logout failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleted: propagate first Err to abort the transaction.
|
||||||
|
pub async fn dispatch_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
for h in &self.hooks {
|
||||||
|
h.on_user_deleted(user, mode, tx).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `is_external` flag (additive migration)
|
||||||
|
|
||||||
|
New migration `migrations/20260612000002_auth_users_is_external.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Adds the is_external flag distinguishing storage-owning internal users
|
||||||
|
-- from grant-only external users (magic-link / OIDC-only / future OCM).
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_external BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Partial index — most queries are "list internal users" or "list
|
||||||
|
-- external users for GDPR purge", never an unfiltered scan.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_is_external_login
|
||||||
|
ON auth.users (is_external, last_login_at)
|
||||||
|
WHERE is_external = TRUE;
|
||||||
|
|
||||||
|
-- Guard against accidental storage attribution to external users.
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD CONSTRAINT users_external_no_storage
|
||||||
|
CHECK (NOT is_external OR storage_used_bytes = 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
User entity (`src/domain/entities/user.rs`):
|
||||||
|
- Add `is_external: bool` field
|
||||||
|
- Add getter `pub fn is_external(&self) -> bool`
|
||||||
|
- Add factory `User::new_external(username, email, ...)` for the magic-link flow
|
||||||
|
- Existing factories (`User::new(...)`) default `is_external = false`
|
||||||
|
|
||||||
|
The `Subject::External(uuid)` variant in `domain/services/authorization.rs` becomes redundant once external users live in `auth.users` and are addressed as `Subject::User(uuid)`. Deprecate it in a follow-up — out of scope here to avoid scope creep.
|
||||||
|
|
||||||
|
### Concrete hook implementations
|
||||||
|
|
||||||
|
**Each hook impl lives in the module of the service that owns the work**, matching the existing convention (`ThumbnailRefreshHook` lives in `src/infrastructure/services/thumbnail_service.rs`; `AudioMetadataService impl FileLifecycleHook` lives in `audio_metadata_service.rs`). There is **no centralised `lifecycle/` directory** — that would invert ownership and make "lifecycle" look like the owner of folder-creation policy when really the folder service owns it.
|
||||||
|
|
||||||
|
All four trait methods are explicit per impl; no-ops are `Ok(())` one-liners.
|
||||||
|
|
||||||
|
| Hook | Lives in | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | Replaces the 4 eager `create_personal_folder` calls + the self-heal. `on_user_created` & `on_user_login`: if `!user.is_external()` and home folder missing, create "My Folder - {username}". `on_user_deleted` (AdminDelete): trash the home folder. `on_user_deleted` (GdprPurge): hard-delete folder + files. `on_user_logout`: `Ok(())`. |
|
||||||
|
| `AuthzCacheLifecycleHook` | `src/infrastructure/services/pg_acl_engine.rs` (same module as the Moka cache it invalidates) | Wraps `Arc<PgAclEngine>`. `on_user_logout` & `on_user_deleted`: `engine.invalidate_user_groups_cache(user.id())` (new public method on the engine — one line `self.user_groups_cache.invalidate(id).await`). `on_user_created` & `on_user_login`: `Ok(())`. |
|
||||||
|
| `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with the dispatcher — cross-cutting, no domain owner) | All four events: `tracing::info!(target: "audit", event = "user.{created\|login\|logout\|deleted}", user_id = %user.id(), is_external = user.is_external(), ...)`. Stays one place for user-lifecycle audit. |
|
||||||
|
| `SessionRevocationLifecycleHook` | The session-service module (e.g. `src/application/services/session_service.rs` or wherever `revoke_all_user_sessions` lives — verify at PR-write time) | `on_user_deleted`: explicit `session_storage.revoke_all_user_sessions(user.id(), tx)` for traceable audit (the FK CASCADE would do it but produces no per-session audit event). `on_user_logout` / `on_user_login` / `on_user_created`: `Ok(())`. |
|
||||||
|
| `ExternalIdentityLifecycleHook` *(stubbed; populated by the magic-link PR later)* | A future external-identity service module (created with the magic-link PR sequence; for the stub PR, place it in `src/application/services/external_identity_service.rs` as a new module) | `on_user_login`: if `user.is_external()`, bump a `last_verified_at` column on a future `auth.user_external_identity` side-table. Other events: `Ok(())`. Lands as no-op now so the slot exists. |
|
||||||
|
|
||||||
|
**Why owner-located, not lifecycle-located**: it preserves the rule that "code about folders lives in the folder module". A future maintainer reading the folder service sees the lifecycle reactions next to the rest of the folder logic. It also makes a future workspace split (see "Crate-split note" at the end of this plan) almost free — each domain takes its hooks with it.
|
||||||
|
|
||||||
|
### Tips for hook implementors
|
||||||
|
|
||||||
|
These belong in the module-level docstring of `application/ports/user_lifecycle.rs` so the next maintainer reading the trait sees them in IDE hover.
|
||||||
|
|
||||||
|
1. **First-ever login detection.** `on_user_login` fires after credentials validate but **before** `user.last_login_at` is updated for this session. So `user.last_login_at().is_none()` is a reliable "this is the first login since account creation" signal. Use it for welcome emails, one-shot default-folder seeding, "complete your profile" prompts, etc.
|
||||||
|
|
||||||
|
2. **External-user short-circuit.** Every hook that provisions or manages user-owned resources (folders, calendars, address books) should start with `if user.is_external() { return Ok(()); }`. External users are grant-only; they don't own storage. The `CHECK (NOT is_external OR storage_used_bytes = 0)` constraint catches violations at the DB level.
|
||||||
|
|
||||||
|
3. **Idempotency is mandatory.** `on_user_login` fires on every successful authentication. A hook that creates a resource must first check whether the resource already exists. Examples: `HomeFolderLifecycleHook` does `if folder_exists(user_id) { return Ok(()); }` before calling `create_home_folder`. `AuthzCacheLifecycleHook::on_user_logout` is naturally idempotent (cache `invalidate` is a no-op on a missing key).
|
||||||
|
|
||||||
|
4. **First call after `is_external = TRUE → FALSE`.** When admin converts an external user to internal (`UPDATE auth.users SET is_external = FALSE`), the user's next login fires `on_user_login` with the new flag value. The home-folder hook sees `!is_external` and that no folder exists → creates it. No special "convert" event needed; idempotency carries the load.
|
||||||
|
|
||||||
|
5. **Per-session logout firing.** Disabling a user revokes N sessions in a loop. The dispatcher fires `on_user_logout` **once per session revoked**, all with `reason = AccountDisabled`. Hook implementors must accept N redundant calls (idempotent invalidation, idempotent audit) — do **not** assume "one logout = one user state change". The same applies to `revoke_all_user_sessions` on password change.
|
||||||
|
|
||||||
|
6. **Failure swallowing on create/login.** If your hook returns `Err`, the user is still created / logged in; only your hook's effect is delayed. Log enough detail (`tracing::error!`) that a subsequent investigation can identify the user and retry manually. Failure on `on_user_deleted` aborts the transaction — be conservative about returning Err there.
|
||||||
|
|
||||||
|
7. **No transaction handle on create/login/logout.** Only `on_user_deleted` gets `&mut Transaction` because deletion is the only event with hard atomic-with-DB requirements. Other hooks open their own connections / pools as needed. This keeps the trait surface minimal.
|
||||||
|
|
||||||
|
8. **Hook registration is at DI time.** Hook order is registration order; document this in the DI factory if you ever add an ordering dependency (e.g., HomeFolderLifecycleHook before any future hook that wants to write to that folder).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
A new architecture page `docs/architecture/user-lifecycle.md` lands alongside the trait (in PR 1) and grows incrementally with each subsequent PR. Mirrors the structure of the existing `docs/architecture/file-and-blob-lifecycle.md` so readers familiar with the file-side pattern can navigate the user-side analog.
|
||||||
|
|
||||||
|
**Outline** (~150 lines):
|
||||||
|
|
||||||
|
1. **Context** — why hooks (replaces 4 scattered `create_personal_folder` calls + the self-heal; sets up the magic-link / external-user flow as a pluggable concern).
|
||||||
|
2. **The trait** — full signature, the 4 events, `LogoutReason` / `DeletionMode` enums.
|
||||||
|
3. **Dispatcher semantics** — per-event failure model (log-and-continue for created/login, fire-and-forget spawn for logout, abort-on-Err for deleted-in-transaction). Diagram.
|
||||||
|
4. **Implementation tips** (verbatim from the "Tips for hook implementors" section of this plan — first-login detection via `last_login_at.is_none()`, idempotency, external-user short-circuit, per-session logout firing, …).
|
||||||
|
5. **Owner-located convention** — explains why hooks live with their service module rather than a centralised `lifecycle/` directory, with the FileLifecycleHook precedent.
|
||||||
|
6. **Concrete hooks shipped today** — table of `HomeFolderLifecycleHook` / `AuthzCacheLifecycleHook` / `AuditLifecycleHook` / `SessionRevocationLifecycleHook` / `ExternalIdentityLifecycleHook` (stub) with one-line summaries and where each lives.
|
||||||
|
7. **Recommended future triggers** — the "future triggers" table from this plan (`on_user_password_changed`, `on_user_role_changed`, etc.) so v2 contributors see the design door.
|
||||||
|
8. **File map** — same shape as the file map at the bottom of `rebac-authorization.md`.
|
||||||
|
|
||||||
|
**VitePress sidebar update** in `docs/.vitepress/config.mts`. The Architecture section already lists "File and Blob lifecycle" (line 104); add immediately after:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incidental fix while we're in the file**: `docs/architecture/rebac-authorization.md` (created in a previous session) is missing from the sidebar. Add it in the same edit:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ text: "ReBAC Authorization", link: "/architecture/rebac-authorization" },
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it logically — probably right before "Share Integration" since shares depend on ReBAC concepts.
|
||||||
|
|
||||||
|
**Per-PR doc growth**:
|
||||||
|
- PR 1: sections 1, 2, 3, 4, 5 (trait, dispatcher, conventions) + the AuditLifecycleHook entry in section 6
|
||||||
|
- PR 2: short subsection in section 4 explaining the `is_external` short-circuit pattern
|
||||||
|
- PR 3: HomeFolderLifecycleHook entry in section 6, plus a worked example "what happens when a brand-new user logs in"
|
||||||
|
- PR 4: AuthzCacheLifecycleHook + SessionRevocationLifecycleHook entries, plus the `DeletionMode` section
|
||||||
|
- PR 5: ExternalIdentityLifecycleHook entry + a "this is a placeholder for the upcoming magic-link feature" note
|
||||||
|
|
||||||
|
Sidebar entry lands in PR 1; subsequent PRs only edit the markdown content.
|
||||||
|
|
||||||
|
## Migration sequencing (5 PRs)
|
||||||
|
|
||||||
|
**PR 1: trait + dispatcher + audit hook only.**
|
||||||
|
Lands the trait at `application/ports/user_lifecycle.rs`, the dispatcher at `application/services/user_lifecycle_service.rs`, and `AuditLifecycleHook` as the lone registered hook. Wires `dispatch_created` / `dispatch_login` / `dispatch_logout` / `dispatch_deleted` into the existing 4 auth code paths (no behaviour change for users; only audit log gains four new event types). Zero risk; validates plumbing.
|
||||||
|
|
||||||
|
**PR 2: `is_external` column + entity field.**
|
||||||
|
Migration `20260612000002_auth_users_is_external.sql`, `User::is_external` getter, factory variant, DTO field. All existing rows have `is_external = FALSE` from the column default; no breaking changes. New `POST /api/admin/users` accepts `is_external` (default `false`).
|
||||||
|
|
||||||
|
**PR 3: `HomeFolderLifecycleHook`.**
|
||||||
|
Register the hook. Remove the 4 eager `create_personal_folder` calls in `auth_application_service.rs:283 / 360 / 832 / 1277`. Remove the self-heal at `folder_service.rs:350-365`. Existing test suite should pass — folder still gets created, just by the hook now. The Hurl suite at `tests/api/run.sh` is the canary.
|
||||||
|
|
||||||
|
**PR 4: `AuthzCacheLifecycleHook` + `SessionRevocationLifecycleHook` + `on_user_deleted` policy.**
|
||||||
|
Adds the `pub fn invalidate_user_groups_cache(&self, id: Uuid)` method on `PgAclEngine`. Wires the two hooks. Adds `DeletionMode` switching to `HomeFolderLifecycleHook::on_user_deleted` (trash vs hard-delete). Admin-delete endpoint now passes `mode = AdminDelete`; a (future) GDPR sweeper passes `GdprPurge`.
|
||||||
|
|
||||||
|
**PR 5: `ExternalIdentityLifecycleHook` stub.**
|
||||||
|
Empty no-op hook landed in advance of the magic-link feature so the registration slot exists in DI. Populated in the magic-link PR sequence later.
|
||||||
|
|
||||||
|
After PR 3, the cleanup of `create_personal_folder` from `auth_application_service.rs` is complete and the service stops importing `FolderService` for that purpose.
|
||||||
|
|
||||||
|
## Recommended future triggers (DON'T ship now)
|
||||||
|
|
||||||
|
These are the events users / consumers will eventually want. Each has a "what would make us add it" rationale; absent that, **don't add the method to the trait** — every method adds a no-op to every hook impl forever.
|
||||||
|
|
||||||
|
| Future event | Why someone might want it | What would force adding it |
|
||||||
|
|---|---|---|
|
||||||
|
| `on_user_password_changed` | Notify the user via email; invalidate any cached credentials; trigger TOTP re-enrolment | A real per-user notification service. Today the password-change handler explicitly calls `revoke_all_user_sessions` which fires `on_user_logout(PasswordChanged)` for each session — sufficient for current consumers. |
|
||||||
|
| `on_user_role_changed` | Admin grants admin role → audit + maybe send "you're now an admin" email; admin demotion → revoke admin-only sessions | A multi-role system (today only `admin` / `user` exist). Currently a one-liner audit log at the admin handler covers it. |
|
||||||
|
| `on_user_email_changed` | External users: re-verify the new email via magic-link before trusting it; internal: notify both old and new addresses; update OIDC mapping | When external users start changing their email. Today email is immutable in the API. |
|
||||||
|
| `on_user_username_changed` | Update display names in audit logs that captured the old username; rename the home folder if it embeds the username | When username changes ship. Today username is immutable. |
|
||||||
|
| `on_user_avatar_changed` | Bust thumbnail caches downstream; sync to federated servers (OCM) | When OCM federation ships and remote partners need to learn about avatar changes. Today no downstream consumer. |
|
||||||
|
| `on_user_quota_changed` | Future per-service quota counters react to admin-changed limits | When quota becomes per-service (today it's a single global counter per user). |
|
||||||
|
| `on_user_disabled` / `on_user_enabled` | Audit-distinguishable state changes; pause per-user scheduled jobs | When per-user scheduled jobs land. Today `on_user_logout(AccountDisabled)` covers the only real consumer (sessions). Re-enable triggers `on_user_login` naturally. |
|
||||||
|
| `on_user_external_to_internal_converted` | Welcome email; provision the catalog of internal-only resources at conversion time rather than on next login | If admins routinely promote external users and the next-login lag is unacceptable. Today the idempotent `on_user_login` recheck handles conversion fine. |
|
||||||
|
| `on_user_oidc_linked` / `on_user_oidc_unlinked` | Audit; sync remote profile data | When users can link/unlink OIDC identities post-creation. Today OIDC linkage is fixed at user-creation time. |
|
||||||
|
| `on_user_2fa_enabled` / `on_user_2fa_disabled` | Audit; force re-login of other sessions | When 2FA ships. |
|
||||||
|
|
||||||
|
**Rule of thumb for adding any of these later**: add the trait method with a default `Ok(())` body so existing hooks don't need to declare it explicitly (one-time exception to the "no defaults" rule, paid forever after by IDE-discoverable docstrings on the new method). Make sure the docstring states whether it's await-or-spawn semantics and whether failure aborts the parent operation.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
**New files** (per PR):
|
||||||
|
|
||||||
|
- PR 1: `src/application/ports/user_lifecycle.rs` (trait + `LogoutReason` + `DeletionMode` enums), `src/application/services/user_lifecycle_service.rs` (dispatcher + `AuditLifecycleHook` co-located inside), `docs/architecture/user-lifecycle.md` (architecture doc, outline above)
|
||||||
|
- PR 2: `migrations/20260612000002_auth_users_is_external.sql`
|
||||||
|
- PR 3: No new files — `HomeFolderLifecycleHook` is added as a new `impl UserLifecycleHook for ...` block inside the **existing** `src/application/services/folder_service.rs` (or a sibling `folder_lifecycle.rs` if folder_service.rs gets too large; verify line count at PR-write time)
|
||||||
|
- PR 4: No new files — `AuthzCacheLifecycleHook` added inside the existing `src/infrastructure/services/pg_acl_engine.rs`; `SessionRevocationLifecycleHook` added inside the session-service module
|
||||||
|
- PR 5: `src/application/services/external_identity_service.rs` (new module hosting the stub hook)
|
||||||
|
|
||||||
|
**Modified files**:
|
||||||
|
|
||||||
|
- `src/domain/entities/user.rs` (PR 2): add `is_external` field + getter + factory
|
||||||
|
- `src/application/services/auth_application_service.rs` (PRs 1, 3): wire dispatcher into the 4 create / 3 login / 2 logout / 1 delete sites; remove the 4 eager folder-creation calls in PR 3
|
||||||
|
- `src/application/services/folder_service.rs` (PR 3): add the `HomeFolderLifecycleHook` impl; remove the self-heal at lines 350-365 (now handled by the hook on next login)
|
||||||
|
- `src/infrastructure/services/pg_acl_engine.rs` (PR 4): add `pub fn invalidate_user_groups_cache(&self, id: Uuid)` exposing `user_groups_cache.invalidate(id)`; add the `AuthzCacheLifecycleHook` impl
|
||||||
|
- `src/common/di.rs` (PRs 1, 3, 4, 5): construct the `UserLifecycleService` with builder chain, mirror the `FileLifecycleService` registration pattern at lines 264-301
|
||||||
|
- `src/application/dtos/user_dto.rs` (PR 2): add `is_external: bool` field
|
||||||
|
- `src/interfaces/api/handlers/admin_handler.rs` (PR 2): accept `is_external` in `POST /api/admin/users` request body
|
||||||
|
- `docs/.vitepress/config.mts` (PR 1): add "User lifecycle" entry to the Architecture sidebar (line ~104). Also incidentally add the missing "ReBAC Authorization" entry that pre-dated this work
|
||||||
|
- `docs/architecture/user-lifecycle.md` (PRs 2, 3, 4, 5): grow the doc incrementally as each hook lands — `is_external` short-circuit note in PR 2, HomeFolderLifecycleHook section in PR 3, etc.
|
||||||
|
|
||||||
|
**Existing patterns to reuse**:
|
||||||
|
|
||||||
|
- Hook trait + dispatcher pattern: `src/application/ports/file_lifecycle.rs` + `src/application/services/file_lifecycle_service.rs` (the closest analog)
|
||||||
|
- DI builder chain: `src/common/di.rs:264-301` (FileLifecycleService construction)
|
||||||
|
- Audit tracing convention: `target: "audit"` events emitted by `src/application/services/subject_group_service.rs::create / rename / delete / add_member / remove_member`
|
||||||
|
- Per-cache invalidation method on engine: model after how `user_groups_cache` is accessed today in `src/infrastructure/services/pg_acl_engine.rs::expand_user`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --all-features --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
biome check --fix static/js/
|
||||||
|
tsc -p jsconfig.json --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
After PR 1 (smoke test the plumbing):
|
||||||
|
|
||||||
|
1. `cargo run`, then via the UI: register a new user, log in, log out, delete via admin.
|
||||||
|
2. `journalctl -t oxicloud | grep "target=user_lifecycle"` (or `RUST_LOG=user_lifecycle=info`) — exactly one event line per action.
|
||||||
|
|
||||||
|
After PR 3 (the migration of folder creation):
|
||||||
|
|
||||||
|
1. Hurl suite: `bash tests/api/run.sh` — all 13 test files still pass. `permissions.hurl` is the most relevant (it creates `bob` and verifies the home folder).
|
||||||
|
2. Manual: register a fresh user via the UI → home folder appears in the file list immediately. Then drop the home folder via SQL (`DELETE FROM storage.folders WHERE user_id = $1`), log out, log back in → folder reappears (the safety-net path).
|
||||||
|
3. Confirm via tracing that `dispatch_login` actually ran for an existing user whose folder was already there → no folder creation attempt, no error, just one `on_user_login` audit event.
|
||||||
|
|
||||||
|
After PR 4:
|
||||||
|
|
||||||
|
1. Authz cache: create a user, log them in, log them out. Inspect `RUST_LOG=oxicloud::infrastructure::services::pg_acl_engine=debug` — cache entry should be invalidated immediately on logout, not after 30s TTL.
|
||||||
|
2. User deletion: admin-deletes a user → verify (via audit log) that `on_user_deleted` ran inside the transaction and all sessions were revoked before the `auth.users` row vanished.
|
||||||
|
|
||||||
|
After PR 5: no functional change; just confirm `external_identity_hook.rs` compiles and registers in DI as a no-op.
|
||||||
|
|
||||||
|
## Out of scope (do NOT bundle into these 5 PRs)
|
||||||
|
|
||||||
|
- **The magic-link external-user flow itself.** Lands in a later sequence; this plan only prepares the schema (`is_external`) and the hook slot (`ExternalIdentityLifecycleHook` stub).
|
||||||
|
- **Removing `Subject::External` from the domain.** It's currently unused; the cleanup is a separate small PR after PR 2 demonstrates that external users live in `auth.users`.
|
||||||
|
- **GDPR sweeper.** The `DeletionMode::GdprPurge` variant exists in PR 4 but no sweeper is wired up — admin-delete uses `AdminDelete`. A scheduled sweeper is its own future work.
|
||||||
|
- **Moving the `active` flag transitions through a hook.** PR 4's `on_user_logout(AccountDisabled)` covers it; no `on_user_disabled` method is added (see "future triggers" section).
|
||||||
|
- **Side-table for OIDC/OCM provenance** (`auth.user_external_identity`). Lands with the magic-link PR; not needed for `is_external` alone.
|
||||||
|
|
||||||
|
## Crate-split note (forward-looking, NOT in this work)
|
||||||
|
|
||||||
|
OxiCloud is currently a single Rust crate (~50 kLOC). The lifecycle-hook restructuring above intentionally aligns with the natural domain boundaries (each hook lives with its service) so that a future workspace split is incremental rather than a rewrite. **Not on the table for this work, but worth recording the intended split axis** so subsequent refactors don't paint into a corner:
|
||||||
|
|
||||||
|
- **Split by domain bounded context, NOT by hexagonal layer.** Layered split (`oxicloud-domain` / `oxicloud-application` / etc.) makes the common case painful: adding a field to an entity touches 4 crates. Domain split (`oxicloud-files`, `oxicloud-auth`, `oxicloud-rebac`, …) makes the common case stay in one crate.
|
||||||
|
- Target shape, illustrative:
|
||||||
|
```
|
||||||
|
oxicloud-kernel ← errors, DI primitives, common port traits (incl. UserLifecycleHook)
|
||||||
|
oxicloud-auth ← users, sessions, OIDC, app passwords; dispatcher lives here
|
||||||
|
oxicloud-rebac ← groups, grants, engine; registers AuthzCacheLifecycleHook
|
||||||
|
oxicloud-files ← files, folders, blobs, dedup, thumbnails; registers HomeFolderLifecycleHook
|
||||||
|
oxicloud-sharing ← shares, magic-link, external identity
|
||||||
|
oxicloud-calendar ← caldav
|
||||||
|
oxicloud-contacts ← carddav
|
||||||
|
oxicloud-server ← Axum wire-up, the binary, DI composition root
|
||||||
|
```
|
||||||
|
Each domain crate is internally layered. Cross-crate communication goes through `oxicloud-kernel` port traits. The DI factory at `oxicloud-server` is where crates compose into the full application.
|
||||||
|
|
||||||
|
- **What today's lifecycle work buys for that future split**: zero rework on hook locations. `HomeFolderLifecycleHook` already lives next to `FolderService`, so it moves with `oxicloud-files`. `AuthzCacheLifecycleHook` moves with `oxicloud-rebac`. The dispatcher in `oxicloud-auth` only knows the trait, never the impls.
|
||||||
|
|
||||||
|
- **Cheap things to do now that help the future split**, but are NOT bundled here:
|
||||||
|
- Tighten visibility: prefer `pub(crate)` over `pub` wherever a type isn't intentionally part of the public surface. Catches accidental cross-module reaches at compile time.
|
||||||
|
- Per-domain port traits: today `application/ports/file_lifecycle.rs` is a file-concern port living in the layer dir; eventually it should live under the files-domain module. Refactor when adjacent ports are touched, not as a one-shot move.
|
||||||
|
- Avoid expanding `src/common/` — it tends to absorb anything-shared and become hard to split later.
|
||||||
|
|
||||||
|
These are convention recommendations for future PRs, not work items for this plan.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Add `is_external` flag to auth.users
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Distinguishes storage-owning internal users (default — `is_external = FALSE`)
|
||||||
|
-- from grant-only external users (`is_external = TRUE`). External users are
|
||||||
|
-- recipients of share-grants who do not have a home folder, do not consume
|
||||||
|
-- storage quota, and authenticate via magic-link / OIDC / OCM (future).
|
||||||
|
--
|
||||||
|
-- This migration is additive — all existing rows default to internal. The
|
||||||
|
-- backend code that consumes the flag lands in PR 3 (HomeFolderLifecycleHook
|
||||||
|
-- short-circuits when `is_external = TRUE`) and the magic-link flow ships
|
||||||
|
-- later still.
|
||||||
|
--
|
||||||
|
-- Subject::External(uuid) in the domain becomes redundant after this — every
|
||||||
|
-- principal is now Subject::User(uuid) with `is_external` as a property, not
|
||||||
|
-- a variant. The cleanup happens in a small follow-up after the flag has
|
||||||
|
-- been observed in production.
|
||||||
|
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_external BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Partial index for the two query patterns that scan by this flag:
|
||||||
|
-- - admin "list external users" surface
|
||||||
|
-- - GDPR / cleanup sweepers that filter on `is_external = TRUE` and
|
||||||
|
-- last_login_at older than a threshold.
|
||||||
|
-- Internal-user queries don't go through this index — they ignore the
|
||||||
|
-- column entirely.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_is_external_login
|
||||||
|
ON auth.users (is_external, last_login_at)
|
||||||
|
WHERE is_external = TRUE;
|
||||||
|
|
||||||
|
-- Schema-level safety net: external users must not be charged for storage.
|
||||||
|
-- HomeFolderLifecycleHook (PR 3) short-circuits before creating a home
|
||||||
|
-- folder for them, so storage_used_bytes should stay at 0. This CHECK
|
||||||
|
-- catches any code path that bypasses the hook and tries to attribute
|
||||||
|
-- storage to an external user.
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD CONSTRAINT users_external_no_storage
|
||||||
|
CHECK (NOT is_external OR storage_used_bytes = 0);
|
||||||
|
|
||||||
|
-- Forbid external + admin combination. External users are grant-only
|
||||||
|
-- recipients authenticating via federated identity (magic-link, OIDC,
|
||||||
|
-- future OCM). Granting them the admin role would let a federated
|
||||||
|
-- principal manage the local instance — undesirable. To promote an
|
||||||
|
-- external user to admin: first flip is_external to FALSE (converting
|
||||||
|
-- them to internal), then update the role separately. The two steps
|
||||||
|
-- are intentional friction.
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD CONSTRAINT users_external_not_admin
|
||||||
|
CHECK (NOT (is_external AND role = 'admin'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.is_external IS
|
||||||
|
'TRUE for grant-only external recipients (magic-link, OIDC-only, OCM federated). FALSE for storage-owning internal users. Set at creation; can be flipped to FALSE by admin to convert external → internal (next login provisions the home folder via HomeFolderLifecycleHook).';
|
||||||
@@ -91,9 +91,15 @@ pub struct AdminCreateUserDto {
|
|||||||
/// "admin" or "user"; defaults to "user"
|
/// "admin" or "user"; defaults to "user"
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
|
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
|
||||||
|
/// Ignored when `is_external = true` (external users have no storage).
|
||||||
pub quota_bytes: Option<i64>,
|
pub quota_bytes: Option<i64>,
|
||||||
/// Whether the account is active; defaults to true
|
/// Whether the account is active; defaults to true
|
||||||
pub active: Option<bool>,
|
pub active: Option<bool>,
|
||||||
|
/// `true` to create a grant-only external user (no home folder, no
|
||||||
|
/// storage quota). Defaults to `false` (internal user). External
|
||||||
|
/// users authenticate via magic-link / OIDC / OCM federation —
|
||||||
|
/// password is set but never used.
|
||||||
|
pub is_external: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for admin password reset
|
/// Request body for admin password reset
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ pub struct UserDto {
|
|||||||
pub auth_provider: String,
|
pub auth_provider: String,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub can_edit_image: bool,
|
pub can_edit_image: bool,
|
||||||
|
/// `true` for grant-only external recipients (magic-link, OIDC-only,
|
||||||
|
/// future OCM federated). External users have no home folder and
|
||||||
|
/// can't own storage; their quota is always 0. Internal users
|
||||||
|
/// default to `false`.
|
||||||
|
pub is_external: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<User> for UserDto {
|
impl From<User> for UserDto {
|
||||||
@@ -37,6 +42,7 @@ impl From<User> for UserDto {
|
|||||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||||
image: user.image().map(|s| s.to_string()),
|
image: user.image().map(|s| s.to_string()),
|
||||||
can_edit_image: !user.is_oidc_user(),
|
can_edit_image: !user.is_oidc_user(),
|
||||||
|
is_external: user.is_external(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ pub mod storage_ports;
|
|||||||
pub mod thumbnail_ports;
|
pub mod thumbnail_ports;
|
||||||
pub mod transcode_ports;
|
pub mod transcode_ports;
|
||||||
pub mod trash_ports;
|
pub mod trash_ports;
|
||||||
|
pub mod user_lifecycle;
|
||||||
pub mod zip_ports;
|
pub mod zip_ports;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! User-lifecycle hook port.
|
||||||
|
//!
|
||||||
|
//! Observer notified by [`AuthApplicationService`] when a user transitions
|
||||||
|
//! through one of four lifecycle events: created, login, logout, deleted.
|
||||||
|
//! Register concrete impls with [`UserLifecycleService`] during DI wiring;
|
||||||
|
//! the dispatcher fans out each event to every registered hook.
|
||||||
|
//!
|
||||||
|
//! Each impl owns ONE concern. Folder service owns home-folder provisioning,
|
||||||
|
//! authz engine owns its cache invalidation, audit service owns the audit
|
||||||
|
//! trail, etc. New services plug in by registering a hook; the dispatcher
|
||||||
|
//! itself never gains domain knowledge.
|
||||||
|
//!
|
||||||
|
//! # Convention: explicit no-ops
|
||||||
|
//!
|
||||||
|
//! Every implementor **must** provide all four methods — use an explicit
|
||||||
|
//! one-liner `Ok(())` for events the implementor does not care about. This
|
||||||
|
//! forces conscious acknowledgement of every lifecycle event rather than
|
||||||
|
//! silent omission via trait defaults. Mirrors the [`FileLifecycleHook`]
|
||||||
|
//! convention at `application/ports/file_lifecycle.rs`.
|
||||||
|
//!
|
||||||
|
//! # Convention: async + per-event semantics
|
||||||
|
//!
|
||||||
|
//! Unlike [`FileLifecycleHook`] (sync fire-and-forget), user-lifecycle
|
||||||
|
//! events are async because some require synchronous semantics:
|
||||||
|
//! provisioning must finish before the session token is returned;
|
||||||
|
//! deletion cleanup must commit atomically with the user DELETE.
|
||||||
|
//!
|
||||||
|
//! Per-event failure model (encoded in the dispatcher, not the trait):
|
||||||
|
//!
|
||||||
|
//! | Event | Awaited? | On `Err`? |
|
||||||
|
//! |--------------------|----------|----------------------------------------|
|
||||||
|
//! | `on_user_created` | yes | log-and-continue (retry on next login) |
|
||||||
|
//! | `on_user_login` | yes | log-and-continue (idempotent retry) |
|
||||||
|
//! | `on_user_logout` | no | fire-and-forget (spawned), error logged|
|
||||||
|
//! | `on_user_deleted` | yes (in tx) | abort the transaction (Err propagates) |
|
||||||
|
//!
|
||||||
|
//! # Tips for hook implementors
|
||||||
|
//!
|
||||||
|
//! 1. **First-ever login detection.** `on_user_login` fires after
|
||||||
|
//! credentials validate but **before** `user.register_login()` is
|
||||||
|
//! called for this session. So `user.last_login_at().is_none()` is a
|
||||||
|
//! reliable "this is the first login since account creation" signal —
|
||||||
|
//! useful for welcome emails, one-shot default-resource seeding,
|
||||||
|
//! "complete your profile" prompts.
|
||||||
|
//!
|
||||||
|
//! 2. **External-user short-circuit.** Every hook that provisions or
|
||||||
|
//! manages user-owned resources (folders, calendars, address books)
|
||||||
|
//! should start with `if user.is_external() { return Ok(()); }`.
|
||||||
|
//! External users are grant-only — they don't own storage. The
|
||||||
|
//! `is_external` flag lands in PR 2 of this work; until then, treat
|
||||||
|
//! every user as internal.
|
||||||
|
//!
|
||||||
|
//! 3. **Idempotency is mandatory.** `on_user_login` fires on every
|
||||||
|
//! successful authentication. A hook that creates a resource must
|
||||||
|
//! first check whether the resource already exists. Same for cache
|
||||||
|
//! invalidation, audit deduplication, etc. The `on_user_login`
|
||||||
|
//! safety-net only works if hooks no-op when their work is already
|
||||||
|
//! done.
|
||||||
|
//!
|
||||||
|
//! 4. **External → internal conversion needs no special event.** When
|
||||||
|
//! admin converts an external user to internal (`UPDATE auth.users
|
||||||
|
//! SET is_external = FALSE`), the user's next login fires
|
||||||
|
//! `on_user_login` with the new flag value. Idempotent hooks see
|
||||||
|
//! `!is_external` + missing resources → provision. No
|
||||||
|
//! `on_user_converted` method needed.
|
||||||
|
//!
|
||||||
|
//! 5. **Per-session logout firing.** When a flow revokes multiple
|
||||||
|
//! sessions (e.g. `revoke_all_user_sessions` on password change),
|
||||||
|
//! today the dispatcher fires `on_user_logout` ONCE per logical
|
||||||
|
//! revoke-call. PR 4's `SessionRevocationLifecycleHook` will refine
|
||||||
|
//! this to once-per-session for proper audit granularity. Hooks must
|
||||||
|
//! therefore accept N redundant calls with the same reason — keep
|
||||||
|
//! them idempotent and side-effect-free.
|
||||||
|
//!
|
||||||
|
//! 6. **Failure swallowing on create/login.** If your hook returns
|
||||||
|
//! `Err`, the user is still created / logged in; only your hook's
|
||||||
|
//! effect is delayed. Log enough detail via `tracing::error!` that a
|
||||||
|
//! subsequent investigation can identify the user and retry
|
||||||
|
//! manually. The `on_user_login` safety-net will retry on the next
|
||||||
|
//! successful authentication.
|
||||||
|
//!
|
||||||
|
//! 7. **`on_user_deleted` runs inside the delete transaction.** The
|
||||||
|
//! user row still exists when the hook fires; the dispatcher commits
|
||||||
|
//! only after every hook returns `Ok(())`. Returning `Err` aborts
|
||||||
|
//! the whole transaction — including the user DELETE itself.
|
||||||
|
//! Implementors get `tx: &mut sqlx::Transaction<'_, Postgres>` so
|
||||||
|
//! cleanup queries land in the same tx (e.g. session revocation
|
||||||
|
//! with audit trail before FK CASCADE wipes the rows). Be
|
||||||
|
//! conservative about returning `Err`: an abort means the admin's
|
||||||
|
//! delete operation fails, leaving the user intact.
|
||||||
|
//!
|
||||||
|
//! 8. **Hook order is registration order.** The DI factory at
|
||||||
|
//! [`AppServiceFactory`] determines the firing sequence. If two hooks
|
||||||
|
//! have an ordering dependency (e.g. home-folder must exist before
|
||||||
|
//! default-calendar can be seeded inside it), the dependent hook
|
||||||
|
//! registers AFTER the producer. Document the convention in the DI
|
||||||
|
//! block where order matters.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::user::User;
|
||||||
|
|
||||||
|
/// Reason a user session is ending. Hooks that don't care about the cause
|
||||||
|
/// (e.g. cache invalidation) ignore the value; audit-style hooks branch on
|
||||||
|
/// it to emit distinguishable events.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LogoutReason {
|
||||||
|
/// User clicked logout. Single-session.
|
||||||
|
UserInitiated,
|
||||||
|
/// Session TTL hit. Single-session.
|
||||||
|
SessionExpired,
|
||||||
|
/// Admin invoked single-session revocation (e.g. "log out other
|
||||||
|
/// devices"). Today this fires from `logout_all` and from individual
|
||||||
|
/// admin endpoints if/when they exist.
|
||||||
|
AdminRevoked,
|
||||||
|
/// `user.active` flipped to `FALSE` → all sessions revoked. Fires once
|
||||||
|
/// per logical revoke-call today (see tip #5).
|
||||||
|
AccountDisabled,
|
||||||
|
/// Password was changed → sibling sessions invalidated to force re-login
|
||||||
|
/// with the new password.
|
||||||
|
PasswordChanged,
|
||||||
|
/// Refresh-token reuse detected by the session-family guard. Entire
|
||||||
|
/// family revoked because the rotation was probably stolen.
|
||||||
|
TokenReused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How aggressively `on_user_deleted` cleanup should run. Today both
|
||||||
|
/// variants are equivalent (only `AuditLifecycleHook` exists, and it logs
|
||||||
|
/// regardless). The split exists so PR 4's `HomeFolderLifecycleHook` can
|
||||||
|
/// trash on `AdminDelete` but hard-delete on `GdprPurge`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeletionMode {
|
||||||
|
/// Admin deletes a user through the UI. Resources move to trash for
|
||||||
|
/// the retention window; recoverable.
|
||||||
|
AdminDelete,
|
||||||
|
/// GDPR right-to-erasure sweeper. Hard-delete everything; not
|
||||||
|
/// recoverable. (No sweeper is wired today; the variant is reserved.)
|
||||||
|
GdprPurge,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observer for user-lifecycle events. See module-level docstring for the
|
||||||
|
/// convention, semantics, and 8 tips for implementors.
|
||||||
|
///
|
||||||
|
/// `#[async_trait]` is required to make the trait `dyn`-compatible —
|
||||||
|
/// the dispatcher holds `Arc<dyn UserLifecycleHook>`. Without it,
|
||||||
|
/// native `async fn in trait` returns an opaque type that has no vtable
|
||||||
|
/// representation. The same crate (`async-trait` 0.1.x) is used by other
|
||||||
|
/// async ecosystem deps and was already transitively in Cargo.lock.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserLifecycleHook: Send + Sync {
|
||||||
|
/// Short identifier used in tracing / error logs. Example: `"home_folder"`,
|
||||||
|
/// `"audit"`, `"authz_cache"`.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Fires once after INSERT into `auth.users` succeeds, regardless of
|
||||||
|
/// the creation path (self-register, admin-create, OIDC JIT, future
|
||||||
|
/// magic-link bootstrap).
|
||||||
|
///
|
||||||
|
/// The dispatcher logs `Err` and continues — the user is still
|
||||||
|
/// created and the next `on_user_login` will run an idempotent retry.
|
||||||
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires after every successful authentication, BEFORE the user's
|
||||||
|
/// `last_login_at` is updated for this session and BEFORE the session
|
||||||
|
/// token is returned to the caller.
|
||||||
|
///
|
||||||
|
/// **Idempotency is mandatory** — this fires on every login, not just
|
||||||
|
/// the first. Hooks that provision must check whether their resource
|
||||||
|
/// already exists before creating it. See tip #3 in the module
|
||||||
|
/// docstring.
|
||||||
|
///
|
||||||
|
/// `user.last_login_at().is_none()` distinguishes the first-ever
|
||||||
|
/// login from subsequent ones. See tip #1.
|
||||||
|
async fn on_user_login(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires on session termination. `reason` lets hooks distinguish
|
||||||
|
/// causes — audit cares; cache invalidation usually doesn't.
|
||||||
|
///
|
||||||
|
/// Spawned by the dispatcher — `Err` is logged but never propagates.
|
||||||
|
/// The HTTP response shouldn't wait for downstream cache flushes.
|
||||||
|
async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fires inside the `delete_user_admin` transaction, BEFORE the
|
||||||
|
/// `DELETE FROM auth.users` row removal. The user row still exists
|
||||||
|
/// at this point; `user.id()` is safe to reference in queries on
|
||||||
|
/// the same `tx`. Returning `Err` rolls back the transaction —
|
||||||
|
/// the user is NOT deleted and the admin's request fails. See
|
||||||
|
/// tip #7 in the module docstring.
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ use crate::application::ports::auth_ports::{
|
|||||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||||
UserStoragePort,
|
UserStoragePort,
|
||||||
};
|
};
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||||
use crate::application::services::folder_service::FolderService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::OidcConfig;
|
use crate::common::config::OidcConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::entities::session::Session;
|
use crate::domain::entities::session::Session;
|
||||||
@@ -71,7 +71,12 @@ pub struct AuthApplicationService {
|
|||||||
session_storage: Arc<SessionPgRepository>,
|
session_storage: Arc<SessionPgRepository>,
|
||||||
password_hasher: Arc<Argon2PasswordHasher>,
|
password_hasher: Arc<Argon2PasswordHasher>,
|
||||||
token_service: Arc<JwtTokenService>,
|
token_service: Arc<JwtTokenService>,
|
||||||
folder_service: Option<Arc<FolderService>>,
|
/// Dispatcher for user-lifecycle events. `None` only in tests that don't
|
||||||
|
/// exercise the lifecycle path; production DI always wires this.
|
||||||
|
/// HomeFolderLifecycleHook (registered on this dispatcher) owns the
|
||||||
|
/// per-user folder provisioning that AuthApplicationService used to do
|
||||||
|
/// inline pre-PR 3.
|
||||||
|
user_lifecycle: Option<Arc<UserLifecycleService>>,
|
||||||
/// Path to the storage directory, used for disk-space–aware quota calculation
|
/// Path to the storage directory, used for disk-space–aware quota calculation
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
oidc: RwLock<OidcState>,
|
oidc: RwLock<OidcState>,
|
||||||
@@ -96,7 +101,7 @@ impl AuthApplicationService {
|
|||||||
session_storage,
|
session_storage,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
token_service,
|
token_service,
|
||||||
folder_service: None,
|
user_lifecycle: None,
|
||||||
storage_path,
|
storage_path,
|
||||||
oidc: RwLock::new(OidcState {
|
oidc: RwLock::new(OidcState {
|
||||||
service: None,
|
service: None,
|
||||||
@@ -159,9 +164,11 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures the folder service, needed to create personal folders
|
/// Configures the user-lifecycle dispatcher. Wired by the DI factory
|
||||||
pub fn with_folder_service(mut self, folder_service: Arc<FolderService>) -> Self {
|
/// after core services are up. PR 1: only AuditLifecycleHook is
|
||||||
self.folder_service = Some(folder_service);
|
/// registered, so calls without this configured silently no-op.
|
||||||
|
pub fn with_user_lifecycle(mut self, lifecycle: Arc<UserLifecycleService>) -> Self {
|
||||||
|
self.user_lifecycle = Some(lifecycle);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,9 +286,12 @@ impl AuthApplicationService {
|
|||||||
// Save user
|
// Save user
|
||||||
let created_user = self.user_storage.create_user(user).await?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
|
|
||||||
// Create personal folder for the user
|
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
|
||||||
self.create_personal_folder(&dto.username, created_user.id())
|
// creation (was inlined here pre-PR 3); audit log + future
|
||||||
.await;
|
// provisioning steps land here too.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_created(&created_user).await;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("User registered: {}", created_user.id());
|
tracing::info!("User registered: {}", created_user.id());
|
||||||
Ok(UserDto::from(created_user))
|
Ok(UserDto::from(created_user))
|
||||||
@@ -356,9 +366,13 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
let created_user = self.user_storage.create_user(user).await?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
|
|
||||||
// Create personal folder for the admin
|
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||||
self.create_personal_folder(&username, created_user.id())
|
// HomeFolderLifecycleHook fired here.
|
||||||
.await;
|
// Lifecycle: HomeFolderLifecycleHook provisions the admin's
|
||||||
|
// home folder. Audit logs the creation event.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_created(&created_user).await;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Initial admin created via setup: {} ({})",
|
"Initial admin created via setup: {} ({})",
|
||||||
@@ -401,6 +415,13 @@ impl AuthApplicationService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||||
|
// observing `last_login_at().is_none()` see "first ever login"
|
||||||
|
// correctly. See tip #1 in user_lifecycle.rs.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_login(&user).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
user.register_login();
|
user.register_login();
|
||||||
self.user_storage.update_user(user.clone()).await?;
|
self.user_storage.update_user(user.clone()).await?;
|
||||||
@@ -496,6 +517,13 @@ impl AuthApplicationService {
|
|||||||
self.session_storage
|
self.session_storage
|
||||||
.revoke_session_family(session.family_id())
|
.revoke_session_family(session.family_id())
|
||||||
.await?;
|
.await?;
|
||||||
|
// Lifecycle: TokenReused logout — fired once per logical
|
||||||
|
// revoke-family call. PR 4 may refine to per-session firing.
|
||||||
|
if let Some(lc) = &self.user_lifecycle
|
||||||
|
&& let Ok(user) = self.user_storage.get_user_by_id(session.user_id()).await
|
||||||
|
{
|
||||||
|
lc.dispatch_logout(user, LogoutReason::TokenReused);
|
||||||
|
}
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"Auth",
|
"Auth",
|
||||||
@@ -576,6 +604,15 @@ impl AuthApplicationService {
|
|||||||
// Revoke session
|
// Revoke session
|
||||||
self.session_storage.revoke_session(session.id()).await?;
|
self.session_storage.revoke_session(session.id()).await?;
|
||||||
|
|
||||||
|
// Lifecycle: notify hooks. One extra DB roundtrip per logout
|
||||||
|
// (user load) is acceptable — logout is rare. Failure to load
|
||||||
|
// the user is non-fatal: we already revoked the session.
|
||||||
|
if let Some(lc) = &self.user_lifecycle
|
||||||
|
&& let Ok(user) = self.user_storage.get_user_by_id(user_id).await
|
||||||
|
{
|
||||||
|
lc.dispatch_logout(user, LogoutReason::UserInitiated);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,13 +674,19 @@ impl AuthApplicationService {
|
|||||||
user.update_password_hash(new_hash);
|
user.update_password_hash(new_hash);
|
||||||
|
|
||||||
// Save updated user
|
// Save updated user
|
||||||
self.user_storage.update_user(user).await?;
|
self.user_storage.update_user(user.clone()).await?;
|
||||||
|
|
||||||
// Optional: revoke all sessions to force re-login with new password
|
// Optional: revoke all sessions to force re-login with new password
|
||||||
self.session_storage
|
self.session_storage
|
||||||
.revoke_all_user_sessions(user_id)
|
.revoke_all_user_sessions(user_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Lifecycle: PasswordChanged logout — fired once per logical
|
||||||
|
// revoke-all call. PR 4 may refine to per-session firing.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_logout(user, LogoutReason::PasswordChanged);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,21 +845,60 @@ impl AuthApplicationService {
|
|||||||
_ => UserRole::User,
|
_ => UserRole::User,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine quota, capped to available disk space
|
let is_external = dto.is_external.unwrap_or(false);
|
||||||
let quota = dto.quota_bytes.unwrap_or_else(|| self.capped_quota(&role));
|
|
||||||
|
|
||||||
// Hash password
|
// Forbid external + admin combo. The DB `users_external_not_admin`
|
||||||
|
// CHECK constraint would catch this too, but a 400 with an
|
||||||
|
// explanatory message is friendlier than a generic 500 from a
|
||||||
|
// constraint violation. See the CHECK definition in
|
||||||
|
// migrations/20260612000002_auth_users_is_external.sql for the
|
||||||
|
// rationale.
|
||||||
|
if is_external && matches!(role, UserRole::Admin) {
|
||||||
|
return Err(DomainError::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"User",
|
||||||
|
"External users cannot be admins. To promote an external user to admin, \
|
||||||
|
first convert them to internal (set is_external = false), then update \
|
||||||
|
the role separately."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// External users never own storage. The DB `users_external_no_storage`
|
||||||
|
// CHECK constraint enforces this; setting quota=0 here keeps the
|
||||||
|
// domain consistent and matches `User::new_external`.
|
||||||
|
let quota = if is_external {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
dto.quota_bytes.unwrap_or_else(|| self.capped_quota(&role))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash password (kept for both internal and external users — for
|
||||||
|
// external users it's currently unused since they authenticate via
|
||||||
|
// magic-link / OIDC, but the DB column is NOT NULL).
|
||||||
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
||||||
|
|
||||||
// Create domain entity
|
// Create domain entity. External path uses `new_external` so the
|
||||||
let user =
|
// is_external flag is set + the EXTERNAL placeholder password
|
||||||
User::new(dto.username.clone(), email, password_hash, role, quota).map_err(|e| {
|
// marker is applied for clarity in DB inspection. `new_external`
|
||||||
DomainError::new(
|
// forces role=User (the admin+external combo was rejected above).
|
||||||
ErrorKind::InvalidInput,
|
let user = if is_external {
|
||||||
"User",
|
User::new_external(dto.username.clone(), email).map(|mut u| {
|
||||||
format!("Error creating user: {}", e),
|
// The hashed password from the request is unused for auth
|
||||||
)
|
// but is persisted so audit-trail integrity is preserved.
|
||||||
})?;
|
u.update_password_hash(password_hash);
|
||||||
|
u
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
User::new(dto.username.clone(), email, password_hash, role, quota)
|
||||||
|
}
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"User",
|
||||||
|
format!("Error creating user: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Persist
|
// Persist
|
||||||
let created = self.user_storage.create_user(user).await?;
|
let created = self.user_storage.create_user(user).await?;
|
||||||
@@ -828,11 +910,19 @@ impl AuthApplicationService {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create personal folder
|
// Lifecycle: HomeFolderLifecycleHook handles the home-folder
|
||||||
self.create_personal_folder(&dto.username, created.id())
|
// provisioning (idempotent + short-circuits on is_external).
|
||||||
.await;
|
// Audit logs the creation event.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_created(&created).await;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Admin created user: {} ({})", dto.username, created.id());
|
tracing::info!(
|
||||||
|
"Admin created user: {} ({}, is_external={})",
|
||||||
|
dto.username,
|
||||||
|
created.id(),
|
||||||
|
created.is_external()
|
||||||
|
);
|
||||||
Ok(UserDto::from(created))
|
Ok(UserDto::from(created))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,12 +968,47 @@ impl AuthApplicationService {
|
|||||||
Ok(UserDto::from(user))
|
Ok(UserDto::from(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a user by ID (admin only)
|
/// Delete a user by ID (admin only).
|
||||||
|
///
|
||||||
|
/// Runs the whole flow in a single transaction so the lifecycle
|
||||||
|
/// hooks (`SessionRevocationLifecycleHook` revoking sessions with
|
||||||
|
/// audit, `AuthzCacheLifecycleHook` invalidating the Moka cache,
|
||||||
|
/// `HomeFolderLifecycleHook` for future trash policy, …) can do
|
||||||
|
/// their work atomically with the user DELETE. If any hook returns
|
||||||
|
/// `Err`, the transaction rolls back and the user remains intact.
|
||||||
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
|
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
// Prevent deleting yourself
|
|
||||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
|
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
|
||||||
self.user_storage.delete_user(user_id).await
|
|
||||||
|
let mut tx = self
|
||||||
|
.user_storage
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("Auth", format!("begin tx: {}", e)))?;
|
||||||
|
|
||||||
|
// Hooks run inside the tx, BEFORE the user DELETE. They see the
|
||||||
|
// row still present and can write cleanup queries against the
|
||||||
|
// same tx (e.g. session revocation with per-session audit).
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_deleted(&user, DeletionMode::AdminDelete, &mut tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the DELETE — FK CASCADE handles the downstream cleanup
|
||||||
|
// (sessions, folders, files, …) for anything the hooks didn't
|
||||||
|
// explicitly remove.
|
||||||
|
sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("Auth", format!("delete user: {}", e)))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("Auth", format!("commit: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate or deactivate a user (admin only)
|
/// Activate or deactivate a user (admin only)
|
||||||
@@ -1175,7 +1300,12 @@ impl AuthApplicationService {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mut existing_user) => {
|
Ok(mut existing_user) => {
|
||||||
// User exists — update last login and sync avatar from IdP
|
// User exists — dispatch login BEFORE register_login() so
|
||||||
|
// hooks observe `last_login_at = None` on the very first
|
||||||
|
// login (see tip #1 in the trait docstring).
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_login(&existing_user).await;
|
||||||
|
}
|
||||||
existing_user.register_login();
|
existing_user.register_login();
|
||||||
existing_user.set_image(claims.picture.clone());
|
existing_user.set_image(claims.picture.clone());
|
||||||
self.user_storage.update_user(existing_user.clone()).await?;
|
self.user_storage.update_user(existing_user.clone()).await?;
|
||||||
@@ -1273,9 +1403,14 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
let created_user = self.user_storage.create_user(new_user).await?;
|
let created_user = self.user_storage.create_user(new_user).await?;
|
||||||
|
|
||||||
// Create personal folder
|
// Lifecycle: created (audit + home-folder provisioning) +
|
||||||
self.create_personal_folder(&username, created_user.id())
|
// login (no register_login() for a fresh OIDC user means
|
||||||
.await;
|
// `last_login_at` is naturally None → first-login detection
|
||||||
|
// works). HomeFolderLifecycleHook creates the home folder.
|
||||||
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
|
lc.dispatch_created(&created_user).await;
|
||||||
|
lc.dispatch_login(&created_user).await;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"OIDC user provisioned: {} (provider: {}, sub: {})",
|
"OIDC user provisioned: {} (provider: {}, sub: {})",
|
||||||
@@ -1371,32 +1506,10 @@ impl AuthApplicationService {
|
|||||||
UserRole::User
|
UserRole::User
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to create a personal folder for a new user
|
// `create_personal_folder` was removed in PR 3 of the
|
||||||
async fn create_personal_folder(&self, username: &str, user_id: Uuid) {
|
// UserLifecycleHook migration — home-folder provisioning is now
|
||||||
if let Some(folder_service) = &self.folder_service {
|
// owned by `HomeFolderLifecycleHook` in folder_service.rs and runs
|
||||||
let folder_name = format!("My Folder - {}", username);
|
// via `dispatch_created` / `dispatch_login`.
|
||||||
match folder_service
|
|
||||||
.create_home_folder(user_id, folder_name.clone())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(folder) => {
|
|
||||||
tracing::info!(
|
|
||||||
"Personal folder created for user {}: {} (ID: {})",
|
|
||||||
user_id,
|
|
||||||
folder.name,
|
|
||||||
folder.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to create personal folder for user {}: {}",
|
|
||||||
user_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL-safe base64 encoding without padding (RFC 4648 §5)
|
/// URL-safe base64 encoding without padding (RFC 4648 §5)
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,7 +315,12 @@ impl FolderUseCase for FolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lists folders scoped to a specific owner.
|
/// Lists folders scoped to a specific owner.
|
||||||
/// Self-healing: if listing root folders and none exist, creates a home folder.
|
///
|
||||||
|
/// **Note (post PR 3):** the self-heal block that auto-created a
|
||||||
|
/// home folder when listing returned empty has been removed.
|
||||||
|
/// `HomeFolderLifecycleHook` (registered on `UserLifecycleService`)
|
||||||
|
/// now provisions the folder on `on_user_created` / `on_user_login`,
|
||||||
|
/// idempotently, so the listing path no longer needs to self-heal.
|
||||||
async fn list_folders_with_perms(
|
async fn list_folders_with_perms(
|
||||||
&self,
|
&self,
|
||||||
parent_id: Option<&str>,
|
parent_id: Option<&str>,
|
||||||
@@ -331,62 +336,23 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return self.list_folders(parent_id).await;
|
return self.list_folders(parent_id).await;
|
||||||
} else {
|
|
||||||
// No parent defined grab user's homes
|
|
||||||
let folders = self
|
|
||||||
.folder_storage
|
|
||||||
.list_folders_by_owner(parent_id, caller_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
DomainError::internal_error(
|
|
||||||
"FolderStorage",
|
|
||||||
format!(
|
|
||||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
|
||||||
caller_id, parent_id, e
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if folders.is_empty() {
|
|
||||||
// Self-healing: if listing root folders and none exist, create a home folder
|
|
||||||
// This ensures the frontend always gets a valid userHomeFolderId
|
|
||||||
tracing::info!(
|
|
||||||
"No root folders found for user {}, creating home folder automatically",
|
|
||||||
caller_id
|
|
||||||
);
|
|
||||||
let owner_id_short = {
|
|
||||||
let s = caller_id.to_string();
|
|
||||||
s[..8.min(s.len())].to_string()
|
|
||||||
};
|
|
||||||
// TODO: what about i18n ?
|
|
||||||
let folder_name = format!("My Folder - {}", owner_id_short);
|
|
||||||
match self
|
|
||||||
.folder_storage
|
|
||||||
.create_home_folder(caller_id, folder_name.clone())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(home_folder) => {
|
|
||||||
tracing::info!(
|
|
||||||
"Created home folder '{}' for user {}",
|
|
||||||
folder_name,
|
|
||||||
caller_id
|
|
||||||
);
|
|
||||||
return Ok(vec![FolderDto::from(home_folder)]);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Failed to create home folder for user {}: {}",
|
|
||||||
caller_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
// Return empty list rather than failing - user might not have storage quota, etc.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
|
||||||
}
|
}
|
||||||
|
// No parent → list the user's root folders.
|
||||||
|
let folders = self
|
||||||
|
.folder_storage
|
||||||
|
.list_folders_by_owner(parent_id, caller_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error(
|
||||||
|
"FolderStorage",
|
||||||
|
format!(
|
||||||
|
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||||
|
caller_id, parent_id, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||||
}
|
}
|
||||||
// TODO: move self healing in other part (on account creation on or login ?)
|
|
||||||
|
|
||||||
/// Lists folders with pagination
|
/// Lists folders with pagination
|
||||||
async fn list_folders_paginated(
|
async fn list_folders_paginated(
|
||||||
@@ -637,6 +603,59 @@ impl FolderService {
|
|||||||
|
|
||||||
Ok((rows, next_cursor))
|
Ok((rows, next_cursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Idempotently provision a home folder for a user.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if a folder was newly created, `Ok(false)` if the
|
||||||
|
/// user already had at least one root folder.
|
||||||
|
///
|
||||||
|
/// **System-level operation** — bypasses authz because this runs on
|
||||||
|
/// the user's own behalf (during creation or login provisioning) at a
|
||||||
|
/// point where the caller may be the engine itself, not an HTTP user.
|
||||||
|
/// Callers must be inside trusted code paths (lifecycle hooks).
|
||||||
|
///
|
||||||
|
/// Used by [`HomeFolderLifecycleHook`] on `on_user_created` and
|
||||||
|
/// `on_user_login`. Replaces the old self-heal at the listing path
|
||||||
|
/// and the four eager `create_personal_folder` calls in
|
||||||
|
/// `AuthApplicationService` (removed in the same PR).
|
||||||
|
pub async fn ensure_home_folder(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
let existing = self
|
||||||
|
.folder_storage
|
||||||
|
.list_folders_by_owner(None, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error(
|
||||||
|
"FolderStorage",
|
||||||
|
format!("ensure_home_folder: list root folders: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_name = format!("My Folder - {}", username);
|
||||||
|
self.folder_storage
|
||||||
|
.create_home_folder(user_id, folder_name.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error(
|
||||||
|
"FolderStorage",
|
||||||
|
format!("ensure_home_folder: create: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
tracing::info!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = "home_folder",
|
||||||
|
user_id = %user_id,
|
||||||
|
folder_name = %folder_name,
|
||||||
|
"Home folder provisioned"
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the next-page cursor from the last row of the current page.
|
/// Build the next-page cursor from the last row of the current page.
|
||||||
@@ -690,3 +709,100 @@ fn build_folder_resource_cursor(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HomeFolderLifecycleHook
|
||||||
|
//
|
||||||
|
// Owns home-folder provisioning policy. Replaces:
|
||||||
|
// - the 4 eager `create_personal_folder` calls in AuthApplicationService
|
||||||
|
// (register / setup_create_admin / admin_create_user / OIDC JIT)
|
||||||
|
// - the self-heal at `list_folders_with_perms` when no root folders exist
|
||||||
|
//
|
||||||
|
// Lives in this file (not under a centralised `lifecycle/` directory)
|
||||||
|
// because the folder service owns home-folder policy — see the
|
||||||
|
// "owner-located convention" note in
|
||||||
|
// `docs/architecture/user-lifecycle.md`.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||||
|
use crate::domain::entities::user::User;
|
||||||
|
|
||||||
|
/// Lifecycle hook: provisions and (in PR 4) deprovisions a user's home folder.
|
||||||
|
pub struct HomeFolderLifecycleHook {
|
||||||
|
folder_service: Arc<FolderService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HomeFolderLifecycleHook {
|
||||||
|
pub fn new(folder_service: Arc<FolderService>) -> Self {
|
||||||
|
Self { folder_service }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotent provisioning shared by `on_user_created` and
|
||||||
|
/// `on_user_login`. External users are skipped per tip #2 in the
|
||||||
|
/// trait docstring.
|
||||||
|
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
if user.is_external() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// `ensure_home_folder` handles the "does the user already have a
|
||||||
|
// root folder?" check internally and is a no-op if so.
|
||||||
|
self.folder_service
|
||||||
|
.ensure_home_folder(user.id(), user.username())
|
||||||
|
.await
|
||||||
|
.map(|_created| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserLifecycleHook for HomeFolderLifecycleHook {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"home_folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
self.provision_if_needed(user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login is the safety net — if `on_user_created` failed at any
|
||||||
|
/// earlier point (or the user was created in a flow that pre-dated
|
||||||
|
/// this hook), provisioning happens here on next login.
|
||||||
|
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
self.provision_if_needed(user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
|
// Folders don't react to logout. Explicit no-op per the
|
||||||
|
// "no defaults" convention.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
// For both DeletionMode variants today the FK CASCADE on
|
||||||
|
// `storage.folders.user_id` (and downstream files/blobs)
|
||||||
|
// removes the home folder + contents when the user row goes.
|
||||||
|
// The hook emits a per-mode tracing event so audit can tell
|
||||||
|
// AdminDelete (currently recoverable only via DB-level rollback
|
||||||
|
// before commit) from GdprPurge (no sweeper exists yet — the
|
||||||
|
// variant is reserved for a future PR that adds retention).
|
||||||
|
//
|
||||||
|
// The `tx` is provided per the trait contract but unused here:
|
||||||
|
// emitting a tracing event doesn't require DB access. Future
|
||||||
|
// policy (trash with retention) would write to `storage.trash`
|
||||||
|
// inside this same tx.
|
||||||
|
tracing::info!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = "home_folder",
|
||||||
|
user_id = %user.id(),
|
||||||
|
mode = ?mode,
|
||||||
|
"Home folder will be removed via FK CASCADE on user delete"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod blob_lifecycle_service;
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
pub mod contact_service;
|
pub mod contact_service;
|
||||||
pub mod device_auth_service;
|
pub mod device_auth_service;
|
||||||
|
pub mod external_identity_service;
|
||||||
pub mod favorites_service;
|
pub mod favorites_service;
|
||||||
pub mod file_lifecycle_service;
|
pub mod file_lifecycle_service;
|
||||||
pub mod file_management_service;
|
pub mod file_management_service;
|
||||||
@@ -25,6 +26,7 @@ pub mod storage_settings_service;
|
|||||||
pub mod storage_usage_service;
|
pub mod storage_usage_service;
|
||||||
pub mod subject_group_service;
|
pub mod subject_group_service;
|
||||||
pub mod trash_service;
|
pub mod trash_service;
|
||||||
|
pub mod user_lifecycle_service;
|
||||||
pub mod wopi_lock_service;
|
pub mod wopi_lock_service;
|
||||||
pub mod wopi_token_service;
|
pub mod wopi_token_service;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! User-lifecycle dispatcher + the always-on `AuditLifecycleHook`.
|
||||||
|
//!
|
||||||
|
//! [`UserLifecycleService`] aggregates every registered
|
||||||
|
//! [`UserLifecycleHook`] and fans out each lifecycle event with
|
||||||
|
//! per-event failure semantics. See `user_lifecycle.rs` for the trait
|
||||||
|
//! contract and tips for implementors.
|
||||||
|
//!
|
||||||
|
//! [`AuditLifecycleHook`] lives in this file (not under
|
||||||
|
//! `infrastructure/services/`) because it's cross-cutting — no domain
|
||||||
|
//! service owns "user-lifecycle audit", and the hook is small enough that
|
||||||
|
//! a separate module would be ceremony. Every other hook lives with the
|
||||||
|
//! service that owns its work (see `architecture/user-lifecycle.md`).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Composite dispatcher for user-lifecycle events.
|
||||||
|
///
|
||||||
|
/// Mirrors the [`FileLifecycleService`] shape: a `Vec<Arc<dyn ...>>` and a
|
||||||
|
/// builder. The per-event failure semantics differ from the file-side
|
||||||
|
/// (file events are sync fire-and-forget; user events have per-method
|
||||||
|
/// rules — see the trait docstring).
|
||||||
|
pub struct UserLifecycleService {
|
||||||
|
hooks: Vec<Arc<dyn UserLifecycleHook>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UserLifecycleService {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserLifecycleService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { hooks: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_hook(mut self, hook: Arc<dyn UserLifecycleHook>) -> Self {
|
||||||
|
self.hooks.push(hook);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Created: log-and-continue. If a hook returns `Err`, the user is
|
||||||
|
/// still created — the next login's `on_user_login` will retry
|
||||||
|
/// idempotently. See tip #6 in the trait docstring.
|
||||||
|
pub async fn dispatch_created(&self, user: &User) {
|
||||||
|
for h in &self.hooks {
|
||||||
|
if let Err(e) = h.on_user_created(user).await {
|
||||||
|
tracing::error!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = h.name(),
|
||||||
|
user_id = %user.id(),
|
||||||
|
error = %e,
|
||||||
|
"on_user_created failed; will retry on next login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login: log-and-continue. Same reasoning as `dispatch_created`.
|
||||||
|
/// Must fire BEFORE `user.register_login()` so that hooks observing
|
||||||
|
/// `last_login_at().is_none()` correctly detect the first-ever login.
|
||||||
|
pub async fn dispatch_login(&self, user: &User) {
|
||||||
|
for h in &self.hooks {
|
||||||
|
if let Err(e) = h.on_user_login(user).await {
|
||||||
|
tracing::error!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = h.name(),
|
||||||
|
user_id = %user.id(),
|
||||||
|
error = %e,
|
||||||
|
"on_user_login failed; will retry on next login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout: fire-and-forget. Spawned so the HTTP response doesn't wait
|
||||||
|
/// for downstream cache flushes. Takes ownership of `User` because the
|
||||||
|
/// spawn outlives the caller's borrow.
|
||||||
|
pub fn dispatch_logout(&self, user: User, reason: LogoutReason) {
|
||||||
|
let hooks = self.hooks.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for h in &hooks {
|
||||||
|
if let Err(e) = h.on_user_logout(&user, reason).await {
|
||||||
|
tracing::error!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = h.name(),
|
||||||
|
reason = ?reason,
|
||||||
|
user_id = %user.id(),
|
||||||
|
error = %e,
|
||||||
|
"on_user_logout failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleted: runs inside the `delete_user_admin` transaction. First
|
||||||
|
/// `Err` propagates and aborts the transaction — the user is NOT
|
||||||
|
/// deleted. Hooks must keep their cleanup conservative. See tip #7
|
||||||
|
/// in the trait docstring.
|
||||||
|
pub async fn dispatch_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
for h in &self.hooks {
|
||||||
|
if let Err(e) = h.on_user_deleted(user, mode, tx).await {
|
||||||
|
tracing::error!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = h.name(),
|
||||||
|
mode = ?mode,
|
||||||
|
user_id = %user.id(),
|
||||||
|
error = %e,
|
||||||
|
"on_user_deleted failed — aborting transaction"
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// AuditLifecycleHook
|
||||||
|
//
|
||||||
|
// Always-on observer. Emits one structured `tracing::info!(target: "audit",
|
||||||
|
// ...)` line per event. The only hook registered in PR 1; subsequent PRs
|
||||||
|
// add HomeFolderLifecycleHook, AuthzCacheLifecycleHook, etc., each living
|
||||||
|
// next to the service it works for.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Cross-cutting audit observer for user-lifecycle events. Co-located with
|
||||||
|
/// the dispatcher because audit has no domain owner.
|
||||||
|
pub struct AuditLifecycleHook;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserLifecycleHook for AuditLifecycleHook {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"audit"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user.created",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
is_external = user.is_external(),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user.login",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
is_external = user.is_external(),
|
||||||
|
first_login = user.last_login_at().is_none(),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user.logout",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
is_external = user.is_external(),
|
||||||
|
reason = ?reason,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
// Audit hook doesn't write to the DB — only emits a tracing
|
||||||
|
// event. The `_tx` is intentionally ignored.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user.deleted",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
is_external = user.is_external(),
|
||||||
|
mode = ?mode,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SessionRevocationLifecycleHook
|
||||||
|
//
|
||||||
|
// Replaces the silent FK CASCADE on `auth.sessions.user_id` with an
|
||||||
|
// explicit `revoke_all_user_sessions` call inside the delete transaction
|
||||||
|
// — emits an aggregate audit event ("user.sessions_revoked_on_delete,
|
||||||
|
// count=N") so the deletion of N sessions is observable, instead of N
|
||||||
|
// rows quietly vanishing via CASCADE.
|
||||||
|
//
|
||||||
|
// Co-located with the dispatcher because there is no dedicated session
|
||||||
|
// service today; the session-storage port is the only consumer. If a
|
||||||
|
// `SessionService` ever emerges, this hook moves there.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||||
|
use crate::infrastructure::repositories::pg::SessionPgRepository;
|
||||||
|
|
||||||
|
/// Lifecycle hook: explicit per-user session revocation on delete with
|
||||||
|
/// audit trail. On any other event: explicit no-op.
|
||||||
|
pub struct SessionRevocationLifecycleHook {
|
||||||
|
session_storage: Arc<SessionPgRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRevocationLifecycleHook {
|
||||||
|
pub fn new(session_storage: Arc<SessionPgRepository>) -> Self {
|
||||||
|
Self { session_storage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserLifecycleHook for SessionRevocationLifecycleHook {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"session_revocation"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
|
// The session causing this logout has already been revoked by
|
||||||
|
// the caller (logout / change_password / etc.). Nothing for this
|
||||||
|
// hook to do.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
// NOTE on `_tx`: ideally this would use the transaction so the
|
||||||
|
// session revocation is atomic with the user DELETE. The current
|
||||||
|
// SessionStoragePort surface doesn't expose a tx-accepting
|
||||||
|
// variant of `revoke_all_user_sessions`, so we revoke against
|
||||||
|
// the same pool. The FK CASCADE on `auth.sessions.user_id`
|
||||||
|
// would clean up any sessions left behind by a rollback anyway,
|
||||||
|
// so the safety net holds.
|
||||||
|
let count = self
|
||||||
|
.session_storage
|
||||||
|
.revoke_all_user_sessions(user.id())
|
||||||
|
.await?;
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user.sessions_revoked_on_delete",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
mode = ?mode,
|
||||||
|
count = count,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-2
@@ -691,12 +691,71 @@ impl AppServiceFactory {
|
|||||||
storage_usage_service =
|
storage_usage_service =
|
||||||
Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool));
|
Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool));
|
||||||
|
|
||||||
// Auth services
|
// User-lifecycle dispatcher. Hook order is registration order;
|
||||||
|
// document dependencies inline if/when any arise. Today:
|
||||||
|
// 1. AuditLifecycleHook — fires first so the
|
||||||
|
// audit event is recorded
|
||||||
|
// even if a later hook
|
||||||
|
// errors out.
|
||||||
|
// 2. HomeFolderLifecycleHook — provisions the user's
|
||||||
|
// home folder on
|
||||||
|
// created/login (no-op
|
||||||
|
// for external users).
|
||||||
|
// 3. AuthzCacheLifecycleHook — invalidates the
|
||||||
|
// Moka group-expansion
|
||||||
|
// cache on logout/delete
|
||||||
|
// so a re-login sees fresh
|
||||||
|
// membership immediately.
|
||||||
|
// 4. SessionRevocationLifecycleHook — explicit per-user
|
||||||
|
// session revocation on
|
||||||
|
// delete (with audit) —
|
||||||
|
// replaces the silent FK
|
||||||
|
// CASCADE.
|
||||||
|
// 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()
|
||||||
|
.with_hook(Arc::new(
|
||||||
|
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
|
||||||
|
))
|
||||||
|
.with_hook(Arc::new(
|
||||||
|
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
|
||||||
|
apps.folder_service_concrete.clone(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.with_hook(Arc::new(
|
||||||
|
crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new(
|
||||||
|
authorization.clone(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.with_hook(Arc::new(
|
||||||
|
crate::application::services::user_lifecycle_service::SessionRevocationLifecycleHook::new(
|
||||||
|
session_repo_for_hook,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.with_hook(Arc::new(
|
||||||
|
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth services. Folder service no longer threaded here —
|
||||||
|
// PR 3 moved home-folder provisioning into
|
||||||
|
// HomeFolderLifecycleHook, which already holds an Arc to the
|
||||||
|
// folder service via the user_lifecycle dispatcher.
|
||||||
if self.config.features.enable_auth {
|
if self.config.features.enable_auth {
|
||||||
let services = crate::infrastructure::auth_factory::create_auth_services(
|
let services = crate::infrastructure::auth_factory::create_auth_services(
|
||||||
&self.config,
|
&self.config,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
Some(apps.folder_service_concrete.clone()),
|
user_lifecycle.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ pub struct User {
|
|||||||
oidc_provider: Option<String>,
|
oidc_provider: Option<String>,
|
||||||
oidc_subject: Option<String>,
|
oidc_subject: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
|
/// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM
|
||||||
|
/// federated). FALSE = storage-owning internal user. Hooks that
|
||||||
|
/// provision per-user resources (home folder, default calendar, …)
|
||||||
|
/// must short-circuit when `is_external` is TRUE — see tip #2 in
|
||||||
|
/// `application/ports/user_lifecycle.rs`. The DB CHECK constraint
|
||||||
|
/// `users_external_no_storage` is the schema-level safety net.
|
||||||
|
is_external: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -85,6 +92,7 @@ impl User {
|
|||||||
oidc_provider: None,
|
oidc_provider: None,
|
||||||
oidc_subject: None,
|
oidc_subject: None,
|
||||||
image: None,
|
image: None,
|
||||||
|
is_external: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +123,48 @@ impl User {
|
|||||||
oidc_provider: Some(oidc_provider),
|
oidc_provider: Some(oidc_provider),
|
||||||
oidc_subject: Some(oidc_subject),
|
oidc_subject: Some(oidc_subject),
|
||||||
image: None,
|
image: None,
|
||||||
|
is_external: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new external user — magic-link / OIDC-only / OCM-federated
|
||||||
|
/// recipient who does NOT own storage. The `CHECK (NOT is_external OR
|
||||||
|
/// storage_used_bytes = 0)` DB constraint enforces the no-storage rule
|
||||||
|
/// at the schema level.
|
||||||
|
///
|
||||||
|
/// **External users are always `UserRole::User`** — there is no role
|
||||||
|
/// parameter because admin + external is an explicitly forbidden
|
||||||
|
/// combination enforced by the `users_external_not_admin` DB CHECK
|
||||||
|
/// constraint. Granting admin to a federated principal would let
|
||||||
|
/// external identity providers indirectly manage the local instance.
|
||||||
|
/// To make an external user an admin: first convert them to internal
|
||||||
|
/// (`UPDATE auth.users SET is_external = FALSE`), then update role.
|
||||||
|
/// The two-step process is intentional friction.
|
||||||
|
///
|
||||||
|
/// Quota is set to 0 because external users can't upload content
|
||||||
|
/// into any folder they own (they have no folder). They can only
|
||||||
|
/// act on grants the resource owner provides — which counts against
|
||||||
|
/// the owner's quota, not theirs.
|
||||||
|
pub fn new_external(username: String, email: String) -> UserResult<Self> {
|
||||||
|
Self::validate_username(&username)?;
|
||||||
|
Self::validate_email(&email)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
Ok(Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash: "__EXTERNAL_NO_PASSWORD__".to_string(),
|
||||||
|
role: UserRole::User,
|
||||||
|
storage_quota_bytes: 0,
|
||||||
|
storage_used_bytes: 0,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login_at: None,
|
||||||
|
active: true,
|
||||||
|
oidc_provider: None,
|
||||||
|
oidc_subject: None,
|
||||||
|
image: None,
|
||||||
|
is_external: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +197,13 @@ impl User {
|
|||||||
oidc_provider: None,
|
oidc_provider: None,
|
||||||
oidc_subject: None,
|
oidc_subject: None,
|
||||||
image: None,
|
image: None,
|
||||||
|
// `from_data` is the minimal-args reconstruction path used by
|
||||||
|
// tests and JWT-claim-based principal hydration (which doesn't
|
||||||
|
// carry `is_external`). Default to FALSE — JWT-validated
|
||||||
|
// principals are existing internal users; magic-link external
|
||||||
|
// sessions take a different path that hydrates from DB via
|
||||||
|
// `from_data_full`.
|
||||||
|
is_external: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +223,7 @@ impl User {
|
|||||||
oidc_provider: Option<String>,
|
oidc_provider: Option<String>,
|
||||||
oidc_subject: Option<String>,
|
oidc_subject: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
|
is_external: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -182,6 +240,7 @@ impl User {
|
|||||||
oidc_provider,
|
oidc_provider,
|
||||||
oidc_subject,
|
oidc_subject,
|
||||||
image,
|
image,
|
||||||
|
is_external,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +301,14 @@ impl User {
|
|||||||
self.image.as_deref()
|
self.image.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `TRUE` for grant-only external recipients (magic-link, OIDC-only,
|
||||||
|
/// OCM federated). Hooks provisioning per-user resources must
|
||||||
|
/// short-circuit when this returns `true` — see tip #2 in
|
||||||
|
/// `application/ports/user_lifecycle.rs`.
|
||||||
|
pub fn is_external(&self) -> bool {
|
||||||
|
self.is_external
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_image(&mut self, image: Option<String>) {
|
pub fn set_image(&mut self, image: Option<String>) {
|
||||||
self.image = image;
|
self.image = image;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||||
use crate::application::services::folder_service::FolderService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::AppConfig;
|
use crate::common::config::AppConfig;
|
||||||
use crate::common::di::AuthServices;
|
use crate::common::di::AuthServices;
|
||||||
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
||||||
@@ -15,7 +15,7 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
|||||||
pub async fn create_auth_services(
|
pub async fn create_auth_services(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
folder_service: Option<Arc<FolderService>>,
|
user_lifecycle: Arc<UserLifecycleService>,
|
||||||
) -> Result<AuthServices> {
|
) -> Result<AuthServices> {
|
||||||
// Create JWT token service (TokenServicePort implementation)
|
// Create JWT token service (TokenServicePort implementation)
|
||||||
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
|
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
|
||||||
@@ -44,10 +44,11 @@ pub async fn create_auth_services(
|
|||||||
config.storage_path.clone(),
|
config.storage_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure folder service if available
|
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
|
||||||
if let Some(folder_svc) = folder_service {
|
// now handled by HomeFolderLifecycleHook (registered on the
|
||||||
auth_app_service = auth_app_service.with_folder_service(folder_svc);
|
// dispatcher in DI) — AuthApplicationService no longer needs a
|
||||||
}
|
// direct FolderService dependency for that path.
|
||||||
|
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||||
|
|
||||||
// Configure OIDC service if enabled
|
// Configure OIDC service if enabled
|
||||||
if config.oidc.enabled {
|
if config.oidc.enabled {
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ impl UserPgRepository {
|
|||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrowed access to the connection pool. Exposed so callers can
|
||||||
|
/// open transactions that span this repo and other repos / hooks
|
||||||
|
/// (e.g. `AuthApplicationService::delete_user_admin` opening a tx
|
||||||
|
/// that wraps the lifecycle dispatcher + the DELETE).
|
||||||
|
pub fn pool(&self) -> &PgPool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method to map SQL errors to domain errors
|
// Helper method to map SQL errors to domain errors
|
||||||
pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
|
pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
|
||||||
match err {
|
match err {
|
||||||
@@ -84,13 +92,13 @@ impl UserRepository for UserPgRepository {
|
|||||||
let _result = sqlx::query(
|
let _result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO auth.users (
|
INSERT INTO auth.users (
|
||||||
id, username, email, password_hash, role,
|
id, username, email, password_hash, role,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject
|
oidc_provider, oidc_subject, is_external
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||||
$12, $13
|
$12, $13, $14
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
@@ -108,6 +116,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.is_active())
|
.bind(user_clone.is_active())
|
||||||
.bind(user_clone.oidc_provider())
|
.bind(user_clone.oidc_provider())
|
||||||
.bind(user_clone.oidc_subject())
|
.bind(user_clone.oidc_subject())
|
||||||
|
.bind(user_clone.is_external())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -131,7 +140,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -163,6 +172,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +184,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -206,6 +216,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +228,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -249,6 +260,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +366,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
@@ -391,6 +403,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -406,7 +419,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE username ILIKE $1 OR email ILIKE $1
|
WHERE username ILIKE $1 OR email ILIKE $1
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
@@ -443,6 +456,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -529,7 +543,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE role::text = $1
|
WHERE role::text = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -565,6 +579,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -600,7 +615,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image
|
oidc_provider, oidc_subject, image, is_external
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||||
"#,
|
"#,
|
||||||
@@ -632,6 +647,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_provider"),
|
row.get("oidc_provider"),
|
||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
|
row.get("is_external"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ impl PgAclEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the cached transitive-group expansion for one user, forcing
|
||||||
|
/// the next `expand_user(uid)` to walk the recursive CTE again.
|
||||||
|
///
|
||||||
|
/// Called by [`AuthzCacheLifecycleHook`] on `on_user_logout` /
|
||||||
|
/// `on_user_deleted` so a re-login (or a re-created account with the
|
||||||
|
/// same id) doesn't observe stale memberships during the 30 s TTL
|
||||||
|
/// window. Cheap — moka's `invalidate` is a single concurrent-map op.
|
||||||
|
pub async fn invalidate_user_groups_cache(&self, user_id: Uuid) {
|
||||||
|
self.user_groups_cache.invalidate(&user_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand a user subject into the set of subject UUIDs that should match
|
/// Expand a user subject into the set of subject UUIDs that should match
|
||||||
/// in `access_grants`: the user's own UUID, every group the user is
|
/// in `access_grants`: the user's own UUID, every group the user is
|
||||||
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
||||||
@@ -1590,3 +1601,72 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
Ok(result.rows_affected() as usize)
|
Ok(result.rows_affected() as usize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// AuthzCacheLifecycleHook
|
||||||
|
//
|
||||||
|
// Owns invalidation of the `user_groups_cache` Moka entry when a user's
|
||||||
|
// state changes in ways that affect transitive-group expansion (logout
|
||||||
|
// — so a re-login with new group memberships doesn't observe a stale
|
||||||
|
// expansion during the 30 s TTL window; delete — so a re-created
|
||||||
|
// account with the same id doesn't inherit the old cached value).
|
||||||
|
//
|
||||||
|
// Lives in this file (not under a centralised `lifecycle/` directory)
|
||||||
|
// because the authz engine owns its own cache invariants. See the
|
||||||
|
// "owner-located convention" note in
|
||||||
|
// `docs/architecture/user-lifecycle.md`.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||||
|
use crate::domain::entities::user::User;
|
||||||
|
|
||||||
|
/// Lifecycle hook: drops the `user_groups_cache` entry for one user on
|
||||||
|
/// logout / deletion so the next authz check rebuilds it from current
|
||||||
|
/// `subject_group_members` rows.
|
||||||
|
pub struct AuthzCacheLifecycleHook {
|
||||||
|
engine: Arc<PgAclEngine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthzCacheLifecycleHook {
|
||||||
|
pub fn new(engine: Arc<PgAclEngine>) -> Self {
|
||||||
|
Self { engine }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserLifecycleHook for AuthzCacheLifecycleHook {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"authz_cache"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
||||||
|
// New user can't have a stale cache entry (no prior `expand_user`
|
||||||
|
// call has produced one). Explicit no-op per the trait convention.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
||||||
|
// Login doesn't change group membership; the cache (if present)
|
||||||
|
// is still correct.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_logout(&self, user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
|
self.engine.invalidate_user_groups_cache(user.id()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_user_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
_mode: DeletionMode,
|
||||||
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
// No DB writes here — just memory invalidation. `_tx` is
|
||||||
|
// intentionally ignored.
|
||||||
|
self.engine.invalidate_user_groups_cache(user.id()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user