Merge pull request #413 from EdouardVanbelle/chore/user-lifecycle
This commit is contained in:
Generated
+1
@@ -3652,6 +3652,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
|
||||
@@ -24,6 +24,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
futures = "0.3.32"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.83"
|
||||
mime_guess = "2.0.5"
|
||||
uuid = { version = "1.23.0", features = ["v4", "v7", "serde"] }
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -110,10 +110,12 @@ export default defineConfig({
|
||||
{ text: "Resource Listing API", link: "/architecture/resource-listing" },
|
||||
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
||||
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
||||
{ text: "ReBAC Authorization", link: "/architecture/rebac-authorization" },
|
||||
{ text: "Share Integration", link: "/architecture/share-integration" },
|
||||
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
||||
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
|
||||
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||
],
|
||||
},
|
||||
{ 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"
|
||||
pub role: Option<String>,
|
||||
/// 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>,
|
||||
/// Whether the account is active; defaults to true
|
||||
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
|
||||
|
||||
@@ -19,6 +19,11 @@ pub struct UserDto {
|
||||
pub auth_provider: String,
|
||||
pub image: Option<String>,
|
||||
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 {
|
||||
@@ -37,6 +42,7 @@ impl From<User> for UserDto {
|
||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
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 transcode_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod user_lifecycle;
|
||||
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,
|
||||
UserStoragePort,
|
||||
};
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::session::Session;
|
||||
@@ -71,7 +71,12 @@ pub struct AuthApplicationService {
|
||||
session_storage: Arc<SessionPgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
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
|
||||
storage_path: PathBuf,
|
||||
oidc: RwLock<OidcState>,
|
||||
@@ -96,7 +101,7 @@ impl AuthApplicationService {
|
||||
session_storage,
|
||||
password_hasher,
|
||||
token_service,
|
||||
folder_service: None,
|
||||
user_lifecycle: None,
|
||||
storage_path,
|
||||
oidc: RwLock::new(OidcState {
|
||||
service: None,
|
||||
@@ -159,9 +164,11 @@ impl AuthApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the folder service, needed to create personal folders
|
||||
pub fn with_folder_service(mut self, folder_service: Arc<FolderService>) -> Self {
|
||||
self.folder_service = Some(folder_service);
|
||||
/// Configures the user-lifecycle dispatcher. Wired by the DI factory
|
||||
/// after core services are up. PR 1: only AuditLifecycleHook is
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -279,9 +286,12 @@ impl AuthApplicationService {
|
||||
// Save user
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Create personal folder for the user
|
||||
self.create_personal_folder(&dto.username, created_user.id())
|
||||
.await;
|
||||
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
|
||||
// creation (was inlined here pre-PR 3); audit log + future
|
||||
// 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());
|
||||
Ok(UserDto::from(created_user))
|
||||
@@ -356,9 +366,13 @@ impl AuthApplicationService {
|
||||
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Create personal folder for the admin
|
||||
self.create_personal_folder(&username, created_user.id())
|
||||
.await;
|
||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||
// HomeFolderLifecycleHook fired here.
|
||||
// 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!(
|
||||
"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
|
||||
user.register_login();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
@@ -496,6 +517,13 @@ impl AuthApplicationService {
|
||||
self.session_storage
|
||||
.revoke_session_family(session.family_id())
|
||||
.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(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
@@ -576,6 +604,15 @@ impl AuthApplicationService {
|
||||
// Revoke session
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -637,13 +674,19 @@ impl AuthApplicationService {
|
||||
user.update_password_hash(new_hash);
|
||||
|
||||
// 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
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -802,21 +845,60 @@ impl AuthApplicationService {
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
// Determine quota, capped to available disk space
|
||||
let quota = dto.quota_bytes.unwrap_or_else(|| self.capped_quota(&role));
|
||||
let is_external = dto.is_external.unwrap_or(false);
|
||||
|
||||
// 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?;
|
||||
|
||||
// Create domain entity
|
||||
let user =
|
||||
User::new(dto.username.clone(), email, password_hash, role, quota).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
format!("Error creating user: {}", e),
|
||||
)
|
||||
})?;
|
||||
// Create domain entity. External path uses `new_external` so the
|
||||
// is_external flag is set + the EXTERNAL placeholder password
|
||||
// marker is applied for clarity in DB inspection. `new_external`
|
||||
// forces role=User (the admin+external combo was rejected above).
|
||||
let user = if is_external {
|
||||
User::new_external(dto.username.clone(), email).map(|mut u| {
|
||||
// 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
|
||||
let created = self.user_storage.create_user(user).await?;
|
||||
@@ -828,11 +910,19 @@ impl AuthApplicationService {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Create personal folder
|
||||
self.create_personal_folder(&dto.username, created.id())
|
||||
.await;
|
||||
// Lifecycle: HomeFolderLifecycleHook handles the home-folder
|
||||
// provisioning (idempotent + short-circuits on is_external).
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -878,12 +968,47 @@ impl AuthApplicationService {
|
||||
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> {
|
||||
// Prevent deleting yourself
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
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)
|
||||
@@ -1175,7 +1300,12 @@ impl AuthApplicationService {
|
||||
.await
|
||||
{
|
||||
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.set_image(claims.picture.clone());
|
||||
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?;
|
||||
|
||||
// Create personal folder
|
||||
self.create_personal_folder(&username, created_user.id())
|
||||
.await;
|
||||
// Lifecycle: created (audit + home-folder provisioning) +
|
||||
// login (no register_login() for a fresh OIDC user means
|
||||
// `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!(
|
||||
"OIDC user provisioned: {} (provider: {}, sub: {})",
|
||||
@@ -1371,32 +1506,10 @@ impl AuthApplicationService {
|
||||
UserRole::User
|
||||
}
|
||||
|
||||
/// Helper to create a personal folder for a new user
|
||||
async fn create_personal_folder(&self, username: &str, user_id: Uuid) {
|
||||
if let Some(folder_service) = &self.folder_service {
|
||||
let folder_name = format!("My Folder - {}", username);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `create_personal_folder` was removed in PR 3 of the
|
||||
// UserLifecycleHook migration — home-folder provisioning is now
|
||||
// owned by `HomeFolderLifecycleHook` in folder_service.rs and runs
|
||||
// via `dispatch_created` / `dispatch_login`.
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -331,62 +336,23 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.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
|
||||
async fn list_folders_paginated(
|
||||
@@ -637,6 +603,59 @@ impl FolderService {
|
||||
|
||||
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.
|
||||
@@ -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 contact_service;
|
||||
pub mod device_auth_service;
|
||||
pub mod external_identity_service;
|
||||
pub mod favorites_service;
|
||||
pub mod file_lifecycle_service;
|
||||
pub mod file_management_service;
|
||||
@@ -25,6 +26,7 @@ pub mod storage_settings_service;
|
||||
pub mod storage_usage_service;
|
||||
pub mod subject_group_service;
|
||||
pub mod trash_service;
|
||||
pub mod user_lifecycle_service;
|
||||
pub mod wopi_lock_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 =
|
||||
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 {
|
||||
let services = crate::infrastructure::auth_factory::create_auth_services(
|
||||
&self.config,
|
||||
pool.clone(),
|
||||
Some(apps.folder_service_concrete.clone()),
|
||||
user_lifecycle.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -36,6 +36,13 @@ pub struct User {
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: 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 {
|
||||
@@ -85,6 +92,7 @@ impl User {
|
||||
oidc_provider: None,
|
||||
oidc_subject: None,
|
||||
image: None,
|
||||
is_external: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +123,48 @@ impl User {
|
||||
oidc_provider: Some(oidc_provider),
|
||||
oidc_subject: Some(oidc_subject),
|
||||
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_subject: 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_subject: Option<String>,
|
||||
image: Option<String>,
|
||||
is_external: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -182,6 +240,7 @@ impl User {
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +301,14 @@ impl User {
|
||||
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>) {
|
||||
self.image = image;
|
||||
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>>;
|
||||
|
||||
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::di::AuthServices;
|
||||
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
||||
@@ -15,7 +15,7 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
pub async fn create_auth_services(
|
||||
config: &AppConfig,
|
||||
pool: Arc<PgPool>,
|
||||
folder_service: Option<Arc<FolderService>>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
) -> Result<AuthServices> {
|
||||
// Create JWT token service (TokenServicePort implementation)
|
||||
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
|
||||
@@ -44,10 +44,11 @@ pub async fn create_auth_services(
|
||||
config.storage_path.clone(),
|
||||
);
|
||||
|
||||
// Configure folder service if available
|
||||
if let Some(folder_svc) = folder_service {
|
||||
auth_app_service = auth_app_service.with_folder_service(folder_svc);
|
||||
}
|
||||
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
|
||||
// now handled by HomeFolderLifecycleHook (registered on the
|
||||
// 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
|
||||
if config.oidc.enabled {
|
||||
|
||||
@@ -27,6 +27,14 @@ impl UserPgRepository {
|
||||
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
|
||||
pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
|
||||
match err {
|
||||
@@ -84,13 +92,13 @@ impl UserRepository for UserPgRepository {
|
||||
let _result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.users (
|
||||
id, username, email, password_hash, role,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
id, username, email, password_hash, role,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
oidc_provider, oidc_subject, is_external
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13
|
||||
$12, $13, $14
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
@@ -108,6 +116,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.is_active())
|
||||
.bind(user_clone.oidc_provider())
|
||||
.bind(user_clone.oidc_subject())
|
||||
.bind(user_clone.is_external())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -131,7 +140,7 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -163,6 +172,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
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,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -206,6 +216,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
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,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -249,6 +260,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
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,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
@@ -391,6 +403,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -406,7 +419,7 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE username ILIKE $1 OR email ILIKE $1
|
||||
ORDER BY username
|
||||
@@ -443,6 +456,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -529,7 +543,7 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -565,6 +579,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -600,7 +615,7 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -632,6 +647,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
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
|
||||
/// in `access_grants`: the user's own UUID, every group the user is
|
||||
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
||||
@@ -1590,3 +1601,72 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
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