refactor(oidc): prep. support of Open Cloud Mesh
add federation kind (OCM, OIDC, MagicLink)
rename oidc_provider into federation_issuer
rename oidc_subject into federation_subject
This commit is contained in:
@@ -0,0 +1,419 @@
|
|||||||
|
# Federated Login — Autodiscovery Without RP Registration
|
||||||
|
|
||||||
|
The goal: a user types `alice@example.com` (or a URL), OxiCloud discovers
|
||||||
|
their identity provider via a well-known probe on `example.com`, initiates
|
||||||
|
whatever auth flow that provider speaks, and gets back a stable
|
||||||
|
`(issuer, subject)` pair. **Zero pre-registration of OxiCloud with every
|
||||||
|
provider.** Fully-meshed federation — no social-login intermediary.
|
||||||
|
|
||||||
|
This is separate from [OCM](./ocm.md) which federates SHARED RESOURCES
|
||||||
|
between clouds. Federated login federates IDENTITY: authenticating a user
|
||||||
|
whose home isn't ours.
|
||||||
|
|
||||||
|
## Why "no RP registration" matters
|
||||||
|
|
||||||
|
Every social-login flow today (Google Sign-In, Sign in with Apple, GitHub
|
||||||
|
OAuth) requires the RP to register upfront in the provider's admin console:
|
||||||
|
get a client_id, configure redirect URIs, get approval. This works for
|
||||||
|
centralized RPs but breaks the mesh: every OxiCloud instance would need to
|
||||||
|
register with every user's IdP.
|
||||||
|
|
||||||
|
The dream is what BrowserID *almost* was — RP discovers identity
|
||||||
|
provisioning from the user's domain, no bilateral setup, works for anyone
|
||||||
|
who hosts an identity endpoint.
|
||||||
|
|
||||||
|
## Landscape — sorted by fit and status
|
||||||
|
|
||||||
|
### Alive and directly-useful
|
||||||
|
|
||||||
|
**IndieAuth (W3C, active spec)** — literal answer to the "no registration"
|
||||||
|
ask. Discovery via HTML `<link rel="authorization_endpoint">` on the
|
||||||
|
identity URL. The **RP's URL IS its client identifier** — no registration
|
||||||
|
step. Auth flow returns a `me` URL as canonical identity.
|
||||||
|
- Ecosystem: strong in IndieWeb (Aperture, Micropub servers, IndieAuth.com).
|
||||||
|
- Adoption outside IndieWeb: thin.
|
||||||
|
- Best for: small-mesh federation of self-hosted OIDC-adjacent IdPs.
|
||||||
|
- Fails for: users whose IdP is Google / Microsoft.
|
||||||
|
|
||||||
|
**WebFinger (RFC 7033)** — user-supplied discovery.
|
||||||
|
`GET https://example.com/.well-known/webfinger?resource=acct:alice@example.com`
|
||||||
|
returns a JRD listing the identity's endpoints, including
|
||||||
|
`rel="http://openid.net/specs/connect/1.0/issuer"` → OIDC issuer URL.
|
||||||
|
- NOT a login mechanism, just a discovery layer.
|
||||||
|
- Deployed at scale (Mastodon uses it for account discovery).
|
||||||
|
- Pair with OIDC or IndieAuth for the actual login.
|
||||||
|
|
||||||
|
**OIDC Dynamic Client Registration (RFC 7591 + OIDC Registration 1.0)** —
|
||||||
|
machine-driven RP registration. RP POSTs to `registration_endpoint`
|
||||||
|
(advertised in OIDC discovery) with its metadata, gets back `client_id` /
|
||||||
|
`client_secret`. Fully server-to-server, no admin console.
|
||||||
|
- Support matrix:
|
||||||
|
- ✅ Keycloak, Authentik, Zitadel, Kanidm, Ory Hydra
|
||||||
|
- ⚠️ Auth0, Okta (with policy configuration)
|
||||||
|
- ❌ Google, Microsoft/Entra, Facebook (disabled by policy)
|
||||||
|
- The pragmatic bridge between "no registration" and "OIDC ecosystem."
|
||||||
|
Works with essentially every self-hosted OIDC server.
|
||||||
|
|
||||||
|
**OpenID Federation 1.0 (finalized 2024)** — the STANDARDIZED future.
|
||||||
|
Each entity publishes a signed entity statement at
|
||||||
|
`/.well-known/openid-federation`; trust chains built through federation
|
||||||
|
authorities (trust anchors). Designed for RP-IdP federation at scale
|
||||||
|
without pairwise registration.
|
||||||
|
- Adoption: EU eIDAS 2.0 wallets, GAIN identity network (banking).
|
||||||
|
- Complexity: needs a trust-anchor infrastructure to be meaningful.
|
||||||
|
- Timeline for OxiCloud: watch, don't implement Day 1.
|
||||||
|
|
||||||
|
**Solid-OIDC** — Solid project's OIDC extension where `client_id` is a
|
||||||
|
dereferenceable URL. Same trick as IndieAuth applied to full OIDC.
|
||||||
|
- Deployed in Solid ecosystem (Inrupt); minimal outside it.
|
||||||
|
- Reasonable to support as a variant of the IndieAuth strategy if demand
|
||||||
|
materialises.
|
||||||
|
|
||||||
|
### Dead or dying — do not build against
|
||||||
|
|
||||||
|
**Mozilla Persona / BrowserID (2011-2016)** — was almost exactly this
|
||||||
|
plan. RP fetched `example.com/.well-known/browserid`, got the primary
|
||||||
|
IdP's signing key, redirected user to primary, primary signed an assertion
|
||||||
|
`{email, iss, sub}`, RP verified signature. Zero RP registration. Elegant
|
||||||
|
design. **Killed by mainstream mail providers refusing to run primaries**;
|
||||||
|
Mozilla's fallback (their own primary verifying via email link) undermined
|
||||||
|
the security story. Lesson for us: any scheme requiring the user's domain
|
||||||
|
to host an identity endpoint depends on domain operators playing along —
|
||||||
|
they mostly don't for public email.
|
||||||
|
|
||||||
|
**OpenID 2.0 (2007-2014)** — original OpenID with XRDS discovery,
|
||||||
|
user-entered URL as identifier, full autodiscovery, no client registration
|
||||||
|
required. Killed by OIDC (which requires registration). Some legacy
|
||||||
|
support in Drupal, MediaWiki. Do not build new.
|
||||||
|
|
||||||
|
**XRI / i-names / OpenXRI** — attempted "personal domain" identifiers with
|
||||||
|
`=name` / `@name` prefixes. Never gained traction. Standards body
|
||||||
|
dissolved. Dead.
|
||||||
|
|
||||||
|
**WebID+TLS** — Solid predecessor. Client certificate-based, no client_id
|
||||||
|
needed at all. Academic circles only. Dead outside research.
|
||||||
|
|
||||||
|
**SXIP** — early single-sign-on protocol, pre-OpenID. Dead.
|
||||||
|
|
||||||
|
**Self-Issued OP (SIOP) v1** — wallet-based auth where the user's device
|
||||||
|
is the IdP. Cool idea, too complicated for a browser-only flow, requires
|
||||||
|
wallet software installed. **v2 is emerging** in EU eIDAS 2.0 context but
|
||||||
|
not for our use case.
|
||||||
|
|
||||||
|
**Verifiable Credentials + SIOP v2** — the OIDC-flavored VC world. Right
|
||||||
|
now: infrastructure-heavy (trust registries, credential formats), user
|
||||||
|
must have a wallet. Watch for the eIDAS 2.0 rollout in 2026-2027; not yet
|
||||||
|
right for us.
|
||||||
|
|
||||||
|
### Adjacent but not this problem
|
||||||
|
|
||||||
|
**OCM invitation flow** — federates SHARED RESOURCES, not identity. See
|
||||||
|
[ocm.md](./ocm.md). Different plan.
|
||||||
|
|
||||||
|
**OAuth 2.0 device flow (RFC 8628)** — solves "log in on a TV using your
|
||||||
|
phone." Not autodiscovery.
|
||||||
|
|
||||||
|
## OxiCloud strategy — WebFinger + strategy-per-provider
|
||||||
|
|
||||||
|
Compose the alive-and-useful pieces:
|
||||||
|
|
||||||
|
```
|
||||||
|
alice@example.com
|
||||||
|
↓
|
||||||
|
discover(identifier)
|
||||||
|
├─ WebFinger probe on example.com (RFC 7033)
|
||||||
|
│ → JRD lists rel="…/openid/1.0/issuer" → OIDC issuer URL
|
||||||
|
│ → JRD lists rel="…/indieauth/…" → IndieAuth authorization_endpoint
|
||||||
|
│ → both = choose OIDC (richer), fall back to IndieAuth on OIDC failure
|
||||||
|
│
|
||||||
|
├─ If WebFinger returns 404 / no useful rel:
|
||||||
|
│ HTML fetch of identity URL, parse <link rel="authorization_endpoint">
|
||||||
|
│ → IndieAuth flow
|
||||||
|
│
|
||||||
|
└─ Else: hard-fail with actionable message
|
||||||
|
"No autodiscovery signal at example.com. Options:
|
||||||
|
(a) ask your admin to enable WebFinger/OIDC on your domain;
|
||||||
|
(b) ask this OxiCloud admin to pre-register example.com;
|
||||||
|
(c) use a supported IdP account."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy A — OIDC + Dynamic Registration:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Discovered issuer URL → fetch /.well-known/openid-configuration
|
||||||
|
↓
|
||||||
|
Cache lookup: auth.oidc_dynamic_clients WHERE issuer = ?
|
||||||
|
├─ hit → reuse cached client_id + client_secret
|
||||||
|
└─ miss → POST registration_endpoint {redirect_uris, client_name, …}
|
||||||
|
get client_id + client_secret → cache row
|
||||||
|
↓
|
||||||
|
Normal OIDC authorize / callback / token flow (PKCE, nonce, etc.)
|
||||||
|
↓
|
||||||
|
id_token → federation_kind='oidc', federation_issuer=iss, federation_subject=sub
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy B — IndieAuth (fallback):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Discovered authorization_endpoint URL (and token_endpoint from same discovery)
|
||||||
|
↓
|
||||||
|
Redirect user with client_id = OxiCloud's own URL, redirect_uri, state, PKCE
|
||||||
|
↓
|
||||||
|
User consents on their IdP
|
||||||
|
↓
|
||||||
|
Callback with code → POST to token_endpoint
|
||||||
|
↓
|
||||||
|
Response has { me: "https://alice.example.com" }
|
||||||
|
↓
|
||||||
|
Store federation_kind='indieauth',
|
||||||
|
federation_issuer=example.com (domain from `me`),
|
||||||
|
federation_subject=me URL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy C — pre-registered (existing OIDC config):** the current
|
||||||
|
single-IdP configured via `OXICLOUD_OIDC_ISSUER_URL` etc. Continues to
|
||||||
|
work; discovery bypassed for that specific issuer. Handles Google /
|
||||||
|
Microsoft cases where dynamic registration isn't available — admin
|
||||||
|
pre-registers, users still get autodiscovery for the "which issuer" step.
|
||||||
|
|
||||||
|
## Schema compatibility — the identity triple already covers this
|
||||||
|
|
||||||
|
The federation-identity model from
|
||||||
|
[ocm.md § Identity & auth model](./ocm.md) uses
|
||||||
|
`(federation_kind, federation_issuer, federation_subject)`. **All the alive
|
||||||
|
strategies fit cleanly** — no schema surgery per strategy, just one CHECK
|
||||||
|
constraint update to admit new `federation_kind` values.
|
||||||
|
|
||||||
|
| Strategy | federation_kind | federation_issuer | federation_subject |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Magic-link (existing) | `magic_link` | NULL | NULL (identity is local `email`) |
|
||||||
|
| OIDC (existing + dynamic) | `oidc` | id_token `iss` (issuer URL) | id_token `sub` |
|
||||||
|
| IndieAuth | `indieauth` | domain from `me` URL | full `me` URL |
|
||||||
|
| OCM (planned) | `ocm` | peer domain | federated address |
|
||||||
|
| OpenID Federation (future) | `openid_fed` | entity-statement `sub` (issuer) | subject id |
|
||||||
|
|
||||||
|
Extending the enum when a strategy lands is a one-line migration:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE auth.users DROP CONSTRAINT users_federation_kind_check;
|
||||||
|
ALTER TABLE auth.users ADD CONSTRAINT users_federation_kind_check
|
||||||
|
CHECK (federation_kind IN ('magic_link','oidc','indieauth','ocm','openid_fed'));
|
||||||
|
```
|
||||||
|
|
||||||
|
BCL revocation, anti-duplicate lookup, session middleware, and the AuthZ
|
||||||
|
engine all key on the triple — kind-blind. Adding IndieAuth or OpenID
|
||||||
|
Federation touches ONLY the discovery + auth-flow code, never the identity
|
||||||
|
storage or authorization layers.
|
||||||
|
|
||||||
|
## What we'd add on top
|
||||||
|
|
||||||
|
**For Strategy A:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE auth.oidc_dynamic_clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
issuer TEXT NOT NULL UNIQUE, -- keyed on issuer URL
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
client_secret TEXT NOT NULL, -- encrypted at rest (blob-encryption path)
|
||||||
|
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
metadata JSONB -- full registration response, for audit
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache of "IdPs we've dynamically registered with." First login per issuer
|
||||||
|
pays ~500ms for the registration round-trip; subsequent logins hit the
|
||||||
|
cache.
|
||||||
|
|
||||||
|
**For Strategy B:** no new table. Client identifier is our URL, discovery
|
||||||
|
metadata is small enough to cache in moka with a short TTL.
|
||||||
|
|
||||||
|
**For discovery layer (shared):** small in-memory + DB cache of
|
||||||
|
`(identifier → strategy + endpoint URLs)` with a TTL matching provider
|
||||||
|
metadata freshness (typical: 1 hour for OIDC discovery per its `Cache-Control`
|
||||||
|
guidance).
|
||||||
|
|
||||||
|
## Trust boundary — policy switch, not a hard-coded stance
|
||||||
|
|
||||||
|
Open discovery = we accept logins from ANY IdP on ANY domain. Three modes,
|
||||||
|
per admin choice:
|
||||||
|
|
||||||
|
**Open federation** (default for personal / community deployments) — allow
|
||||||
|
any WebFinger-discovered IdP. Trust is per-user (user chose to authenticate
|
||||||
|
via that IdP; we trust their choice). Simplest UX, largest attack surface.
|
||||||
|
A malicious IdP can mint arbitrary `sub`s and claim any identity WITHIN
|
||||||
|
ITS OWN DOMAIN (can't impersonate other domains — the `(iss, sub)` key
|
||||||
|
scopes damage). This is the same trust posture as accepting any email
|
||||||
|
address from an SMTP server today.
|
||||||
|
|
||||||
|
**Allowlist federation** (default for enterprise) — admin-managed
|
||||||
|
`ocm.trusted_peers` (reuse the OCM peer allowlist, or a sibling table
|
||||||
|
`auth.trusted_federation_domains`). Only auto-discover for listed
|
||||||
|
domains. Kills the "just enter your email" UX but bounds the attack
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Hybrid** — allow-by-default with per-user quotas + rate limits + admin
|
||||||
|
visibility (`GET /api/admin/federation/discovered_peers` shows every
|
||||||
|
domain we've ever registered against, with revoke button). Middle ground.
|
||||||
|
|
||||||
|
Config knob:
|
||||||
|
|
||||||
|
```
|
||||||
|
OXICLOUD_AUTH_FEDERATION_MODE=open|allowlist|hybrid # default: hybrid
|
||||||
|
OXICLOUD_AUTH_FEDERATION_ALLOWLIST=domain1.com,domain2.com # allowlist mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Same discovery layer serves invitation
|
||||||
|
|
||||||
|
The `discover(identifier) → Strategy` function is not just for login — it
|
||||||
|
answers the same question at INVITE time. When a local user shares with
|
||||||
|
`alice@example.com`, the invite flow probes her domain and picks the
|
||||||
|
strongest available channel:
|
||||||
|
|
||||||
|
```
|
||||||
|
share_with_external(alice@example.com)
|
||||||
|
↓
|
||||||
|
discover(alice@example.com)
|
||||||
|
├─ OCM signal on example.com → OCM outbound share (resource stays here,
|
||||||
|
│ she consumes via her own cloud). Grant
|
||||||
|
│ tied to `federation_kind='ocm'`.
|
||||||
|
├─ OIDC + dynamic reg supported → mint a magic-link that triggers OIDC
|
||||||
|
│ auth on redemption. Grant tied to
|
||||||
|
│ `federation_kind='oidc'` on first login.
|
||||||
|
├─ IndieAuth signal → magic-link that triggers IndieAuth on
|
||||||
|
│ redemption. Grant tied to
|
||||||
|
│ `federation_kind='indieauth'`.
|
||||||
|
├─ OpenID Federation → same idea, `federation_kind='openid_fed'`.
|
||||||
|
└─ Nothing discoverable → traditional email magic-link, mailbox-
|
||||||
|
strength trust. Grant tied to
|
||||||
|
`federation_kind='magic_link'`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preference ordering** (strongest → weakest):
|
||||||
|
|
||||||
|
| Channel | Trust source | Why higher |
|
||||||
|
|---|---|---|
|
||||||
|
| OCM | Peer server + user's own auth on their cloud | Resource federation + strong identity |
|
||||||
|
| OpenID Federation | Signed entity statement + trust anchor chain | Cryptographic RP-IdP trust |
|
||||||
|
| OIDC + dynamic reg | User's IdP (potentially MFA, hardware keys) | IdP-mediated identity |
|
||||||
|
| IndieAuth | User's domain (same domain owns identity + endpoint) | Domain-owner attestation |
|
||||||
|
| Magic-link | Mailbox possession only | Weakest — no MFA, phishable |
|
||||||
|
|
||||||
|
The share dialog UX shows a small badge on the resolved recipient
|
||||||
|
indicating the channel that will be used: `Federated cloud` (OCM),
|
||||||
|
`SSO via IdP.example.com` (OIDC), `IndieAuth on example.com`, or
|
||||||
|
`Email link` (magic-link fallback). Sender sees the trust posture
|
||||||
|
before sending; receiver's invite email / OCM notification reflects
|
||||||
|
the same.
|
||||||
|
|
||||||
|
**Cache the discovery outcome per identifier for a short TTL** (e.g. 24h
|
||||||
|
with revalidation) so repeated invites to the same person don't repeat
|
||||||
|
the probe. Cache lives in `auth.federated_identity_discovery` with
|
||||||
|
columns `(identifier, discovered_kind, discovered_endpoints, cached_at,
|
||||||
|
expires_at)`. Invalidated eagerly if the invite fails (endpoint
|
||||||
|
disappeared, IdP rejected registration) — next attempt re-probes.
|
||||||
|
|
||||||
|
**Trust-mode enforcement applies here too.** In `allowlist` mode, if a
|
||||||
|
recipient's domain isn't on the allowlist, invitation falls back to
|
||||||
|
magic-link regardless of what discovery finds. In `hybrid`/`open` mode,
|
||||||
|
discovery is trusted per-user with rate limiting.
|
||||||
|
|
||||||
|
**Existing external-invite code paths converge here.** Today's magic-link
|
||||||
|
invitation flow (external user gets emailed a redemption link) becomes
|
||||||
|
the "Nothing discoverable" branch. No change to the redemption side —
|
||||||
|
still creates an `is_external=true` shadow user. New paths simply set a
|
||||||
|
richer `federation_kind` on that same shadow user, which downstream
|
||||||
|
enables the stronger auth channel on next login attempt.
|
||||||
|
|
||||||
|
## Interactions with existing auth flow
|
||||||
|
|
||||||
|
- **AUTH_METHODS** — add `federated` as a new allowlist token, analogous
|
||||||
|
to `oidc`. `OXICLOUD_AUTH_METHODS=password,federated` means password
|
||||||
|
login OR autodiscovered federated login. Same fail-fast semantics as
|
||||||
|
the existing OIDC token.
|
||||||
|
- **OIDC-master rule** — `federation_kind='oidc'` OR
|
||||||
|
`federation_kind='indieauth'` OR `federation_kind='openid_fed'` all
|
||||||
|
short-circuit magic-link login for that user. Federation-authenticated
|
||||||
|
users authenticate through their federation. See
|
||||||
|
[ocm.md § Magic-link on federated addresses](./ocm.md).
|
||||||
|
- **JIT provisioning** — same code path as current OIDC JIT-provisioning:
|
||||||
|
create a shadow user on first successful federated login, `is_external`
|
||||||
|
handling depending on whether they should get local storage
|
||||||
|
(`OXICLOUD_AUTH_FEDERATION_JIT_GRANTS_STORAGE=false` by default).
|
||||||
|
- **RP-initiated logout** — reuses the existing `end_session_endpoint`
|
||||||
|
discovery + `id_token_hint` flow for OIDC. IndieAuth has no standard
|
||||||
|
logout; local session revocation only.
|
||||||
|
- **Back-channel logout** — reuses the existing BCL endpoint; the
|
||||||
|
identity key is now unambiguously the `(iss, sub)` pair (fixed by the
|
||||||
|
federation_issuer rename planned in [ocm.md](./ocm.md)).
|
||||||
|
|
||||||
|
## Phasing (rough)
|
||||||
|
|
||||||
|
**Phase 0 — Prerequisite:** federation-identity schema rename (documented
|
||||||
|
in [ocm.md § Schema rename](./ocm.md)). Land first, no matter which
|
||||||
|
federated-login strategy comes next.
|
||||||
|
|
||||||
|
**Phase 1 — WebFinger + OIDC + Dynamic Registration.** Covers the
|
||||||
|
self-hosted OIDC mesh case. ~1-2 weeks. Highest immediate value; runs
|
||||||
|
on top of the existing OIDC flow.
|
||||||
|
|
||||||
|
**Phase 2 — Discovery UX in the login form.** Detect email vs URL,
|
||||||
|
run discovery on submit, show progress spinner while discovering,
|
||||||
|
graceful fallback if no signal. ~3-5 days FE.
|
||||||
|
|
||||||
|
**Phase 3 — IndieAuth strategy.** Small addition once the discovery
|
||||||
|
framework exists. ~1 week.
|
||||||
|
|
||||||
|
**Phase 3b — Discovery reused at INVITE time.** The invitation flow
|
||||||
|
runs the same probe and dispatches to OCM / OIDC / IndieAuth /
|
||||||
|
magic-link based on preference ordering. Recipient badge in the share
|
||||||
|
dialog shows the chosen channel. Cache table
|
||||||
|
`auth.federated_identity_discovery`. ~5-7 days including UX.
|
||||||
|
|
||||||
|
**Phase 4 — Admin panel for discovered peers.** List, revoke, force
|
||||||
|
rebind. ~3-5 days.
|
||||||
|
|
||||||
|
**Phase 5 — Trust-mode policy switches.** Config knobs +
|
||||||
|
allowlist-mode enforcement. ~2-3 days.
|
||||||
|
|
||||||
|
**Phase 6+ — OpenID Federation 1.0.** Add when trust anchor
|
||||||
|
infrastructure matures externally or a deployment specifically needs it.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Federated JIT and quotas** — federated users creating drives? Or
|
||||||
|
strictly grant-only externals? Default to grant-only; per-domain
|
||||||
|
policy could allow drive creation for trusted domains only.
|
||||||
|
- **Discovery-cache invalidation** — when an IdP rotates keys, our
|
||||||
|
cached JWKS goes stale within the OIDC service's TTL (already
|
||||||
|
handled). Discovered issuer + registered client should also expire
|
||||||
|
eventually; add a periodic refresh job.
|
||||||
|
- **Multiple discovery signals on the same domain** — WebFinger says
|
||||||
|
IdP is X, HTML `<link>` says Y. Rule: WebFinger wins (RFC-specified
|
||||||
|
discovery is authoritative over convention).
|
||||||
|
- **Rate limiting** — discovery probes are cheap but can be abused.
|
||||||
|
Per-caller-IP + per-target-domain rate limits.
|
||||||
|
- **Anti-typo** — user types `alice@gogle.com`, we discover Gogle's IdP
|
||||||
|
and successfully register with it. That's a working federated login
|
||||||
|
to a lookalike domain. Mitigation: display the discovered issuer in
|
||||||
|
the consent screen ("You will authenticate at
|
||||||
|
`https://accounts.gogle.com`"). Not fixable server-side alone;
|
||||||
|
user-side confirmation is the last defense.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Persona-style browser-mediated flow (dead)
|
||||||
|
- Verifiable credentials / SIOP v2 (too early)
|
||||||
|
- OpenID 2.0 backwards compatibility (deprecated a decade ago)
|
||||||
|
- Social-login integration (Facebook / GitHub / Twitter buttons) —
|
||||||
|
intentionally out of scope; those require explicit RP registration and
|
||||||
|
a per-provider adapter, not federated identity
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- WebFinger: RFC 7033
|
||||||
|
- OIDC Discovery 1.0: <https://openid.net/specs/openid-connect-discovery-1_0.html>
|
||||||
|
- OIDC Dynamic Client Registration: RFC 7591 + <https://openid.net/specs/openid-connect-registration-1_0.html>
|
||||||
|
- IndieAuth: <https://indieauth.spec.indieweb.org/>
|
||||||
|
- OpenID Federation 1.0: <https://openid.net/specs/openid-federation-1_0.html>
|
||||||
|
- Solid-OIDC: <https://solid.github.io/solid-oidc/>
|
||||||
|
- BrowserID postmortem: <https://wiki.mozilla.org/Identity/Persona_Shutdown_Guidelines_for_Reliers>
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# Open Cloud Mesh (OCM) — Federated Sharing
|
||||||
|
|
||||||
|
Federate file-sharing with Nextcloud, ownCloud, Reva/OCIS, Seafile, and any
|
||||||
|
other OCM 1.1+ speaker. Track the design decisions here before code lands.
|
||||||
|
|
||||||
|
## Identity & auth model — DECIDED
|
||||||
|
|
||||||
|
OCM-federated recipients live as **shadow rows in `auth.users`** with:
|
||||||
|
|
||||||
|
- `is_external = true`
|
||||||
|
- `password_hash = NULL` (enforced by existing `users_external_no_storage`
|
||||||
|
CHECK constraint)
|
||||||
|
- `email = federated-address` (e.g. `bob@nextcloud.example.com`)
|
||||||
|
- Federation columns (see "Schema rename — PRE-REQ" below for the
|
||||||
|
migration path that turns today's OIDC-specific columns into these):
|
||||||
|
- `federation_kind ∈ {NULL, 'magic_link', 'ocm', 'oidc'}` — distinguishes
|
||||||
|
the trust chain that owns this identity. NULL for internal users.
|
||||||
|
- `federation_issuer` — **THE AUTHORITY** that mints the subject id.
|
||||||
|
NOT a display name. The full identity per federation kind is
|
||||||
|
`(federation_issuer, federation_subject)`; the issuer is what makes
|
||||||
|
a subject id meaningful — two different IdPs can independently mint
|
||||||
|
`sub=1234` for two completely different people.
|
||||||
|
- `oidc` — the `iss` claim from the id_token (also
|
||||||
|
`discovery.issuer`), e.g.
|
||||||
|
`https://sso.example.com/realms/main`. Immutable per
|
||||||
|
spec (RFC 7519 §4.1.1).
|
||||||
|
- `ocm` — the peer domain, e.g. `nextcloud.example.com`. The
|
||||||
|
peer's domain IS the authority for its federated identities in
|
||||||
|
OCM's model.
|
||||||
|
- `magic_link` — NULL (identity is the local `email`, no external
|
||||||
|
authority involved).
|
||||||
|
- **Fixes an existing bug.** Today `oidc_provider` stores the
|
||||||
|
human-readable `OXICLOUD_OIDC_PROVIDER_NAME` label (e.g. "SSO",
|
||||||
|
"MockSSO"), not the issuer URL. Consequences of the current
|
||||||
|
shape:
|
||||||
|
1. Admin renaming `OXICLOUD_OIDC_PROVIDER_NAME` silently
|
||||||
|
orphans every existing OIDC user — new lookups key on the
|
||||||
|
new label, stored rows have the old one.
|
||||||
|
2. BCL revocation (`revoke_user_sessions_by_oidc_subject`)
|
||||||
|
keys on the label; same rename → BCL notifications from the
|
||||||
|
IdP match no one. Silent security regression — users
|
||||||
|
thought-kicked stay logged in.
|
||||||
|
3. Multi-IdP futures (multi-realm, multi-tenant) would suffer
|
||||||
|
cross-issuer subject collision if two IdPs share a display
|
||||||
|
name.
|
||||||
|
- Display name becomes a separate concern — either derived from a
|
||||||
|
`peer_configs.issuer → display_name` lookup, or stored alongside
|
||||||
|
as a denormalized `federation_display_name` column (admin-
|
||||||
|
editable, no identity impact). Not part of the identity key.
|
||||||
|
- `federation_subject` — the remote id string. Stability:
|
||||||
|
- `oidc` — the `sub` claim, issuer-assigned, MUST NOT change per spec
|
||||||
|
(RFC 7519 §4.1.2, OIDC Core §5.4). Email can change on the IdP
|
||||||
|
side with no impact on this row.
|
||||||
|
- `ocm` — the full federated address (`bob@nextcloud.example.com`).
|
||||||
|
OCM 1.1 does NOT define a separate stable subject id; the address
|
||||||
|
IS the identity. If a peer renames a user, we see a new address
|
||||||
|
and create a new shadow row. Grants against the old row are
|
||||||
|
orphaned until admin intervention. This is a protocol
|
||||||
|
limitation — some peers ship extensions (Nextcloud Global Site
|
||||||
|
Selector, ScienceMesh/Reva `opaqueUserId`) but they aren't in
|
||||||
|
base OCM. Treat `federation_subject` as opaque; if a future OCM
|
||||||
|
version adds a stable id, swap the value in without schema change.
|
||||||
|
- `magic_link` — NULL (magic-link externals don't carry a federation
|
||||||
|
subject; their identity is the local `email` column).
|
||||||
|
- Composite UNIQUE index `(federation_kind, federation_issuer, federation_subject)`
|
||||||
|
WHERE `federation_kind IS NOT NULL`.
|
||||||
|
- `storage_quota_bytes = 0`, no home folder provisioned
|
||||||
|
|
||||||
|
**Grants stay `subject_type = 'user'` on `storage.role_grants`.** No new
|
||||||
|
subject-type variant. The team's earlier refactor (2026-07-30) removing
|
||||||
|
`'external'` from the CHECK constraint is the precedent — external is a flag
|
||||||
|
on the user row, not a subject type.
|
||||||
|
|
||||||
|
**AuthZ engine unchanged.** Middleware for OCM WebDAV resolves sharedSecret →
|
||||||
|
`ocm.outbound_shares.recipient_user_id` (the shadow row), sets `CurrentUserId`
|
||||||
|
to that, and the existing `authz.require(subject, resource, permission)` runs
|
||||||
|
identically for local + federated subjects.
|
||||||
|
|
||||||
|
## Schema rename — PRE-REQ (ships before ANY OCM code)
|
||||||
|
|
||||||
|
Today's `auth.users.oidc_provider` / `oidc_subject` are semantically identical
|
||||||
|
to the generic `federation_issuer` / `federation_subject` above. Keeping both
|
||||||
|
would be drift risk. Rename first, then start OCM work on top of the clean
|
||||||
|
shape.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE auth.users RENAME COLUMN oidc_provider TO federation_issuer;
|
||||||
|
ALTER TABLE auth.users RENAME COLUMN oidc_subject TO federation_subject;
|
||||||
|
ALTER TABLE auth.users ADD COLUMN federation_kind TEXT
|
||||||
|
CHECK (federation_kind IN ('magic_link', 'ocm', 'oidc'));
|
||||||
|
|
||||||
|
-- Backfill kind: rows that had oidc_provider set were all OIDC-linked.
|
||||||
|
UPDATE auth.users SET federation_kind = 'oidc'
|
||||||
|
WHERE federation_issuer IS NOT NULL;
|
||||||
|
|
||||||
|
-- Swap the anti-duplicate index.
|
||||||
|
DROP INDEX idx_users_oidc;
|
||||||
|
CREATE UNIQUE INDEX idx_users_federation
|
||||||
|
ON auth.users(federation_kind, federation_issuer, federation_subject)
|
||||||
|
WHERE federation_kind IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Value migration is more than a rename** — today's stored values in the
|
||||||
|
renamed `federation_issuer` column are DISPLAY LABELS (e.g. `'MockSSO'`,
|
||||||
|
`'MyIdP'`), not issuer URLs. Rewriting them to real issuer URLs
|
||||||
|
touches every existing OIDC row and can't happen in one atomic step without
|
||||||
|
losing rollback ability. Phase it:
|
||||||
|
|
||||||
|
### Rename PR — Phase A (schema)
|
||||||
|
- Add columns (`federation_kind`, `federation_issuer` renamed from
|
||||||
|
`oidc_provider`, `federation_subject` renamed from `oidc_subject`) —
|
||||||
|
the SQL block above.
|
||||||
|
- Application code DUAL-WRITES on OIDC login: still writes the display
|
||||||
|
label to `federation_issuer` (unchanged semantics) AND stamps
|
||||||
|
`federation_kind = 'oidc'`. Reads unchanged.
|
||||||
|
- Ships safely as a pure rename + kind-backfill. Rollback = drop the
|
||||||
|
`federation_kind` column; the renamed columns keep working with the
|
||||||
|
old code because their VALUES haven't changed.
|
||||||
|
|
||||||
|
### Rename PR — Phase B (progressive lazy rebind on OIDC login)
|
||||||
|
|
||||||
|
**Simplification** — no explicit backfill CLI needed. The id_token
|
||||||
|
carries the true `iss` claim on every OIDC login, so the migration
|
||||||
|
does itself organically:
|
||||||
|
|
||||||
|
- On every successful OIDC login: if the user's stored
|
||||||
|
`federation_issuer` does not equal the id_token's `iss`, UPDATE it
|
||||||
|
(with an audit line `event="federation.issuer_rebound"`,
|
||||||
|
`reason="lazy_backfill"`). First login post-upgrade heals the row.
|
||||||
|
- Users who never log in stay on the legacy label — but they also
|
||||||
|
can't do anything (no login → no action), so the stale value is
|
||||||
|
harmless. It appears only in admin listings, where a "legacy label
|
||||||
|
needs rebind" badge could surface if we care.
|
||||||
|
- Contradiction guard: if the observed `iss` is DIFFERENT from the
|
||||||
|
stored issuer AND the stored issuer is already a valid URL (not a
|
||||||
|
display label), that's a genuine identity change — refuse the login
|
||||||
|
with an audit event and admin alert. Preserves "identity change is
|
||||||
|
admin-mediated." Heuristic for label vs URL: starts with `http://`
|
||||||
|
or `https://` and contains `/`.
|
||||||
|
- Same lazy-rebind logic covers BCL: if a BCL notification's `iss`
|
||||||
|
matches a user's `federation_subject` but issuer differs, we can
|
||||||
|
either rebind (safer bet: same peer just told us their true issuer)
|
||||||
|
or refuse and audit. Recommend refuse-and-audit, since BCL is
|
||||||
|
server-to-server and less user-driven.
|
||||||
|
|
||||||
|
**CLI subcommand shape (optional, ships later if needed)** — lives
|
||||||
|
under `oxicloud-cli federation …` (see `src/bin/oxicloud-cli.rs` for
|
||||||
|
the domain/action pattern; add `federation` module alongside
|
||||||
|
`opaque`). Two subcommands worth having if operators want proactive
|
||||||
|
control:
|
||||||
|
|
||||||
|
- `federation status` — show counts of users by `federation_kind` and
|
||||||
|
how many still hold display-label-shaped `federation_issuer` values.
|
||||||
|
- `federation remap-issuer --from-label X --to-issuer https://...` —
|
||||||
|
bulk-update the users still holding a specific legacy label, for
|
||||||
|
operators who want to close the migration without waiting for
|
||||||
|
every user to log in.
|
||||||
|
|
||||||
|
**All user-identifier arguments accept EITHER username OR user UUID.**
|
||||||
|
Not all users have a username (email-only signups per PR-18 leave it
|
||||||
|
NULL); the CLI must handle both, resolving via
|
||||||
|
`SELECT id FROM auth.users WHERE username = $1 OR id::text = $1`.
|
||||||
|
Applies to any subcommand that takes a user reference.
|
||||||
|
|
||||||
|
- New admin CLI or boot-time task: for each row where
|
||||||
|
`federation_kind = 'oidc'`, try to derive the true issuer URL:
|
||||||
|
- **Single-IdP deployment** (typical): if the current OIDC config's
|
||||||
|
`issuer_url` is set AND no other candidate exists, UPDATE all rows
|
||||||
|
to that issuer. One-line log per row updated.
|
||||||
|
- **Multi-value case** (deployment has renamed
|
||||||
|
`OXICLOUD_OIDC_PROVIDER_NAME` mid-life, so the same physical IdP
|
||||||
|
has rows with different labels): auto-backfill would smear all
|
||||||
|
labels into the SAME issuer — WRONG for rows that predate the
|
||||||
|
label swap. **Refuse to auto-backfill**; emit a boot audit warning
|
||||||
|
listing the distinct current values + affected user_ids; require
|
||||||
|
operator to pick a mapping via CLI (`oxicloud federation
|
||||||
|
remap-issuer --from-label X --to-issuer https://…`) or accept
|
||||||
|
the current-config issuer for all rows via
|
||||||
|
`--all-legacy-labels`.
|
||||||
|
- **Genuine multi-IdP** (rare today, forward-looking): fail loud;
|
||||||
|
no automatic mapping is safe. Manual mapping per label.
|
||||||
|
- Log each mapping decision to `audit` for post-hoc traceability.
|
||||||
|
- Ship after Phase A has been in production long enough to prove
|
||||||
|
stable (one release cycle at minimum).
|
||||||
|
|
||||||
|
### Rename PR — Phase C (read switch + lazy rebind)
|
||||||
|
- Application code reads anti-duplicate lookups AND BCL revocation
|
||||||
|
keying on `(federation_kind, federation_issuer, federation_subject)`.
|
||||||
|
- On EVERY successful OIDC login: if the row's current
|
||||||
|
`federation_issuer` value doesn't equal the id_token's `iss` claim,
|
||||||
|
UPDATE it (with an audit line). This catches rows Phase B couldn't
|
||||||
|
resolve automatically — the first login after rollout self-heals.
|
||||||
|
- Dual-write continues (kind + issuer both stamped from id_token now).
|
||||||
|
- Hard failure mode to watch for: if the `iss` on the id_token
|
||||||
|
contradicts a Phase-B-backfilled value for existing users, we've
|
||||||
|
either (a) misconfigured the IdP, or (b) the IdP is a completely
|
||||||
|
different one than we backfilled to. Refuse the login with an audit
|
||||||
|
event and admin alert rather than silently rebind — preserves the
|
||||||
|
principle "identity change is admin-mediated".
|
||||||
|
|
||||||
|
### Rename PR — Phase D (drop legacy compatibility)
|
||||||
|
- Precondition check: no rows remain with a `federation_issuer` value
|
||||||
|
that looks like a display label (heuristic: doesn't start with
|
||||||
|
`http://` or `https://`, doesn't contain `/`).
|
||||||
|
- Remove any dead code paths that assumed display-label semantics.
|
||||||
|
- No column drop needed — the columns are already renamed. This phase
|
||||||
|
is code-only.
|
||||||
|
- Ships when telemetry from Phase C shows zero unresolved
|
||||||
|
federation_issuer values across the fleet.
|
||||||
|
|
||||||
|
Total: rename in one release cycle, value backfill in the next, read
|
||||||
|
switch after that, cleanup at leisure. Each phase reversible. Operator
|
||||||
|
warnings surface the exceptional cases (multi-label, multi-IdP) so
|
||||||
|
they don't get silent-defaulted into a broken state.
|
||||||
|
|
||||||
|
Rust-side rename (~15-20 sites, mechanical):
|
||||||
|
- `User` entity fields (`oidc_provider` → `federation_issuer` etc.)
|
||||||
|
- `UserPgRepository` SQL statements
|
||||||
|
- `OidcIdClaims → User` mapping in `auth_application_service` (sets
|
||||||
|
`federation_kind = 'oidc'`)
|
||||||
|
- `admin_settings_service`'s env-override table
|
||||||
|
- Any DTOs that leaked `oidc_provider` externally — check `UserDto` before
|
||||||
|
the migration in case FE reads the field
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
Why rename rather than coexist:
|
||||||
|
- `oidc_provider` is a misleading name anyway — a value like
|
||||||
|
`MyIdP` isn't a "provider" (that's a category), it's the IdP's
|
||||||
|
identity/name. `federation_issuer` reads more truthfully.
|
||||||
|
- Adding OCM alongside without renaming forces every future query that asks
|
||||||
|
"is this user externally-owned?" to check two column pairs. One canonical
|
||||||
|
shape is cheaper.
|
||||||
|
|
||||||
|
The rename can ship as an independent PR before OCM work starts. Backfilling
|
||||||
|
`kind = 'oidc'` for existing rows is monotone (no NULL flips), so the
|
||||||
|
migration is reversible if we need to abort.
|
||||||
|
|
||||||
|
## Magic-link on federated addresses — DECIDED
|
||||||
|
|
||||||
|
Default: **`federation_kind='ocm'` users CANNOT use magic-link login.**
|
||||||
|
Same rule shape as the existing OIDC-master rule
|
||||||
|
(`feedback_oidc_master_no_magic_link_bypass`).
|
||||||
|
|
||||||
|
Enforcement in `is_magic_link_login_allowed_for(&user)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
if user.federation_kind == Some(FederationKind::Ocm) {
|
||||||
|
return false; // audit: reason="ocm_federated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- OCM's identity boundary is the remote peer's authentication (which may
|
||||||
|
enforce MFA, geo-fence, hardware keys). Magic-link would downgrade that to
|
||||||
|
mailbox-strength.
|
||||||
|
- Federation revocation on the remote side (peer disables bob) should NOT
|
||||||
|
leave bob with a working login on our side via his mailbox.
|
||||||
|
- Same anti-enumeration shape as `oidc_user` rejection — 404 to the caller,
|
||||||
|
audit line internally.
|
||||||
|
|
||||||
|
### Deferred opt-in: `allow_magic_link_for_ocm_federated`
|
||||||
|
|
||||||
|
Some deployments will want unified UX (federated invite doubles as a browser
|
||||||
|
account). Ship as an additive policy on `OXICLOUD_AUTH_POLICIES`:
|
||||||
|
|
||||||
|
```
|
||||||
|
OXICLOUD_AUTH_POLICIES=allow_magic_link_for_ocm_federated
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape as `permit_magic_link_for_password_users` — off by default,
|
||||||
|
operators who enable it accept the auth-boundary trade-off. Document alongside
|
||||||
|
the shared-computer / weakened-boundary caveat.
|
||||||
|
|
||||||
|
**Not implemented in the initial OCM ship.** Add when a deployment actually
|
||||||
|
asks for it.
|
||||||
|
|
||||||
|
## Reverse case: address already exists as magic-link external
|
||||||
|
|
||||||
|
If `bob@example.com` is already in our DB as a magic-link external (a local
|
||||||
|
user invited him last month) AND a peer sends us an OCM share addressed to
|
||||||
|
`bob@example.com`, do NOT upgrade in place. Create a separate shadow row
|
||||||
|
with `federation_kind='ocm'`. Different trust chains, different identities.
|
||||||
|
The composite UNIQUE index on `(federation_kind, federation_issuer,
|
||||||
|
federation_subject)` allows the two rows to coexist.
|
||||||
|
|
||||||
|
Consequence: magic-link bob CAN log in; OCM bob CANNOT. Same address, two
|
||||||
|
accounts, two postures. Honest about the different trust semantics.
|
||||||
|
|
||||||
|
## New DB shape
|
||||||
|
|
||||||
|
```
|
||||||
|
ocm.trusted_peers -- allowlist of federation partners
|
||||||
|
ocm.outbound_shares -- shares WE created for remote users (with acceptance state)
|
||||||
|
ocm.inbound_shares -- shares OTHER servers created for our users (with acceptance state)
|
||||||
|
ocm.notifications -- wire-level OCM notifications (retry queue + dedup + forensic audit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ocm.notifications` in detail
|
||||||
|
|
||||||
|
**Not to be confused with acceptance state.** When a user clicks
|
||||||
|
Accept/Decline on a pending invitation, that mutates
|
||||||
|
`ocm.inbound_shares.state` — the user's decision lives there. This
|
||||||
|
table stores the *wire message* we then send to the peer telling them
|
||||||
|
"our user accepted." The two are logically distinct: acceptance is
|
||||||
|
share-scoped workflow state, notifications are transport-layer events.
|
||||||
|
Future readers: don't conflate.
|
||||||
|
|
||||||
|
Given that, `ocm.notifications` serves FOUR overlapping purposes.
|
||||||
|
Collapsing them into one table (instead of parallel queue / dedup /
|
||||||
|
audit tables) avoids drift.
|
||||||
|
|
||||||
|
**1. Retry queue for outbound.** OCM 1.1 requires exponential-backoff
|
||||||
|
retry when the peer's `POST /notifications` fails. Rows with
|
||||||
|
`direction='outbound' AND processing_result='pending' AND
|
||||||
|
next_retry_at <= now()` are what a background worker picks up.
|
||||||
|
|
||||||
|
**2. Idempotency / dedup for inbound.** Peers may resend after network
|
||||||
|
hiccups. `UNIQUE(peer_domain, remote_notification_id)` short-circuits.
|
||||||
|
|
||||||
|
**3. Debugging.** "Why did that share disappear?" — grep by
|
||||||
|
`related_share_id`. Payload preserved verbatim as JSONB.
|
||||||
|
|
||||||
|
**4. Security audit.** Federated state transitions cross trust
|
||||||
|
boundaries; every one is auditable, joinable to peer domain + share.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE ocm.notification_direction AS ENUM ('inbound', 'outbound');
|
||||||
|
CREATE TYPE ocm.notification_kind AS ENUM (
|
||||||
|
'share_accepted', 'share_declined', 'share_revoked',
|
||||||
|
'share_unshared', 'user_removed', 'other'
|
||||||
|
);
|
||||||
|
CREATE TYPE ocm.notification_result AS ENUM (
|
||||||
|
'pending', 'success', 'failed', 'ignored'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ocm.notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
direction ocm.notification_direction NOT NULL,
|
||||||
|
peer_domain TEXT NOT NULL,
|
||||||
|
related_share_kind TEXT CHECK (related_share_kind IN ('inbound','outbound')),
|
||||||
|
related_share_id UUID, -- points at inbound_shares.id OR
|
||||||
|
-- outbound_shares.id; typed union
|
||||||
|
-- isn't natural in Postgres so no
|
||||||
|
-- hard FK — verify at insert
|
||||||
|
kind ocm.notification_kind NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
remote_notification_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
processing_result ocm.notification_result NOT NULL DEFAULT 'pending',
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_retry_at TIMESTAMPTZ, -- outbound only
|
||||||
|
error_message TEXT,
|
||||||
|
UNIQUE (peer_domain, remote_notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ocm_notif_related
|
||||||
|
ON ocm.notifications(related_share_id, related_share_kind);
|
||||||
|
CREATE INDEX idx_ocm_notif_retry
|
||||||
|
ON ocm.notifications(next_retry_at)
|
||||||
|
WHERE direction = 'outbound' AND processing_result = 'pending';
|
||||||
|
CREATE INDEX idx_ocm_notif_peer
|
||||||
|
ON ocm.notifications(peer_domain, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry policy** — attempt 1 immediate; 2-5 with exponential backoff
|
||||||
|
(30s, 2m, 10m, 1h); after attempt 5, mark `failed` and emit
|
||||||
|
`event="ocm.notification_delivery_gave_up"` with admin alert.
|
||||||
|
Configurable via `OXICLOUD_OCM_NOTIFICATION_MAX_ATTEMPTS` (default 5).
|
||||||
|
|
||||||
|
**Retention** — 90 days default, purged by a nightly consistency-job
|
||||||
|
handler (`notifications_cleanup`), fits the existing recovery-jobs
|
||||||
|
pattern. `failed` rows kept indefinitely until admin resolution
|
||||||
|
(paper trail). Configurable via
|
||||||
|
`OXICLOUD_OCM_NOTIFICATION_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
**Inbound security invariants** — verify BEFORE mutating any state:
|
||||||
|
|
||||||
|
- Peer domain matches the peer that owns the referenced share
|
||||||
|
(`peer_mismatch` → mark `ignored`, audit).
|
||||||
|
- `related_share_id` points at an existing share in the correct table
|
||||||
|
(`unknown_share` → `ignored`).
|
||||||
|
- Notification kind is compatible with current share state
|
||||||
|
(`state_conflict` → `ignored`, e.g. accept on an already-declined
|
||||||
|
share).
|
||||||
|
|
||||||
|
Each rejection emits an `event="ocm.notification_rejected"` audit line
|
||||||
|
with a stable `reason=` field (`peer_mismatch | unknown_share |
|
||||||
|
state_conflict | duplicate`) — same anti-drift discipline as other
|
||||||
|
structured audit events, per `feedback_enum_over_string_literals_in_logs`.
|
||||||
|
|
||||||
|
Plus `auth.users` new columns above.
|
||||||
|
|
||||||
|
Plus `storage.resource_kind` extension for a `RemoteShare` resource type
|
||||||
|
(pointing at `ocm.inbound_shares.id`), so accepting an inbound share is a
|
||||||
|
regular grant row from the accepting user to the RemoteShare resource.
|
||||||
|
|
||||||
|
## Invitation workflow — where accept/decline lives
|
||||||
|
|
||||||
|
Local grants have no accept/decline concept — the grant IS the access.
|
||||||
|
OCM introduces one because the protocol is push-driven (peer POSTs a
|
||||||
|
share, our user decides whether to surface it). Solved WITHOUT adding
|
||||||
|
state to `role_grants`:
|
||||||
|
|
||||||
|
**Inbound shares (peer → us):**
|
||||||
|
- `POST /ocm/shares` creates an `ocm.inbound_shares` row (pending). NO
|
||||||
|
role_grant created yet.
|
||||||
|
- User sees pending invitation in `/shared-with-me` → `Federated` tab
|
||||||
|
(new), plus a badge on AppShell.
|
||||||
|
- **Accept** → insert `role_grants` row (subject = local user, resource
|
||||||
|
= a new `RemoteShareResource` entry, permission = as offered), POST
|
||||||
|
accept notification to peer, mark `ocm.inbound_shares.accepted_at`.
|
||||||
|
- **Decline** → no grant created; POST decline notification, mark
|
||||||
|
`ocm.inbound_shares.declined_at`. Row kept for audit.
|
||||||
|
- Modal prompt on next login if pending count changed since last login.
|
||||||
|
|
||||||
|
**Outbound shares (us → peer):**
|
||||||
|
- Grant is created immediately in `role_grants` (subject = shadow user
|
||||||
|
for the federated recipient). AuthZ from OUR side is active the
|
||||||
|
moment the OCM POST succeeds — remote user CAN consume via WebDAV.
|
||||||
|
- `ocm.outbound_shares` tracks delivery + acceptance state
|
||||||
|
(`delivering | delivered | accepted | declined_by_recipient |
|
||||||
|
undeliverable | revoked`), surfaced in `/shared` (outgoing view) as
|
||||||
|
a status column.
|
||||||
|
- Share dialog recognizes `user@remote-domain` and shows a channel
|
||||||
|
badge on the resolved recipient (see
|
||||||
|
[federated-login.md § Same discovery layer serves invitation](./federated-login.md)).
|
||||||
|
- Peer sends decline notification → auto-revoke the local grant
|
||||||
|
(recipient said no, keeping access is pointless). Configurable
|
||||||
|
behaviour deferred.
|
||||||
|
|
||||||
|
**ReBAC extensions needed** (all local to the OCM code path, no engine
|
||||||
|
churn):
|
||||||
|
- New `resource_type = 'remote_share'` on `role_grants`, resource_id
|
||||||
|
pointing to `ocm.inbound_shares.id`. AuthZ dispatches to a
|
||||||
|
WebDAV-proxy handler for this type.
|
||||||
|
- `ocm.inbound_shares.state` and `ocm.outbound_shares.state` columns
|
||||||
|
hold acceptance metadata. `role_grants` stays state-free.
|
||||||
|
- OCM notification handler translates peer messages into local state
|
||||||
|
transitions (create/delete grant, update state column).
|
||||||
|
|
||||||
|
## Future — merge with a generic grant-request workflow
|
||||||
|
|
||||||
|
`ocm.inbound_shares.state` is a purpose-specific state machine for the
|
||||||
|
MVP. If OxiCloud later gains a local pending-approval workflow (Alice
|
||||||
|
asks Bob for Read on his folder; Bob approves) — a real
|
||||||
|
enterprise-features ask — the natural refactor is to extract a shared
|
||||||
|
workflow spine:
|
||||||
|
|
||||||
|
- New table `storage.grant_requests` with
|
||||||
|
`(subject, resource, requested_permission, approval_authority, state,
|
||||||
|
decided_at, decided_by)`. Approval_authority distinguishes who holds
|
||||||
|
decide-power: `owner` (local approval), `recipient` (OCM inbound
|
||||||
|
accept, magic-link redeem), `admin` (policy gate).
|
||||||
|
- `ocm.inbound_shares` keeps its federation-specific columns
|
||||||
|
(peer_domain, remote WebDAV URL, sharedSecret) and gains a
|
||||||
|
`grant_request_id` FK. Accept = flip request to `approved` + create
|
||||||
|
`role_grant`. Decline = flip to `rejected`.
|
||||||
|
- `magic_link_tokens` with `resource_kind ≠ NULL` (invitation-purpose
|
||||||
|
tokens) gets the same FK. Redeem = flip to `approved` + create
|
||||||
|
`role_grant`.
|
||||||
|
- **`role_grants` stays approved-only.** Hot AuthZ path doesn't gain a
|
||||||
|
state filter — no regression on read paths.
|
||||||
|
|
||||||
|
**NOT** a merge of the tables themselves — collapsing OCM /
|
||||||
|
magic-link / local-request into `role_grants` with a state column
|
||||||
|
would leak state-machine logic into every AuthZ check. The merge is at
|
||||||
|
the ABSTRACTION level: three flavors of "prospective grant" today
|
||||||
|
(magic-link invite, OCM inbound, hypothetical local request) share ONE
|
||||||
|
workflow table; channel-specific data stays in satellites.
|
||||||
|
|
||||||
|
Migration is additive and non-breaking: add tables + FK columns,
|
||||||
|
backfill existing rows as synthetic `approved` requests for audit
|
||||||
|
continuity, refactor callers one at a time. No ReBAC engine change.
|
||||||
|
|
||||||
|
Not urgent — the OCM MVP works fine without it. Recorded so the
|
||||||
|
option isn't forgotten if local approval workflow ever gets requested.
|
||||||
|
|
||||||
|
## Phasing (rough)
|
||||||
|
|
||||||
|
1. **Phase 0** — Discovery (`/ocm-provider`) + inbound receive + FE listing
|
||||||
|
of received shares (metadata only, no consumption yet). ~1 week.
|
||||||
|
2. **Phase 1** — Consume remote shares (server-side WebDAV proxy). ~1-2 weeks.
|
||||||
|
Needs a WebDAV client — none exists in the tree today.
|
||||||
|
3. **Phase 2** — Outbound sharing (share dialog recognizes `user@remote`).
|
||||||
|
~3-5 days.
|
||||||
|
4. **Phase 3** — OCM 2.0 invite handshake (ScienceMesh trust flow). ~1 week.
|
||||||
|
5. **Phase 4** — Address book / federated-contact registry. ~1 week.
|
||||||
|
|
||||||
|
## Open design questions (not yet decided)
|
||||||
|
|
||||||
|
- **Trust model** — open federation vs allowlisted peers? Recommend
|
||||||
|
allowlist-by-default (safer for self-hosted).
|
||||||
|
- **Recipient resolution rule** — `username@LOCAL_DOMAIN` vs `email` match?
|
||||||
|
Recommend username-based (matches Nextcloud, deterministic).
|
||||||
|
- **Quota accounting** — do consumed remote shares count against the
|
||||||
|
recipient's `storage_quota_bytes`? Recommend no (remote storage is remote).
|
||||||
|
- **Notification authenticity** — bind every OCM notification to its
|
||||||
|
`inbound_share.id`, refuse if peer domain doesn't match the domain we
|
||||||
|
recorded at share creation.
|
||||||
|
- **Group grants** — local group containing OCM-federated user is weird.
|
||||||
|
Semantically fine but the share dialog should probably discourage it.
|
||||||
|
|
||||||
|
## Interop targets
|
||||||
|
|
||||||
|
- Nextcloud in Docker as the primary peer (both directions).
|
||||||
|
- OCIS/Reva as the CS3-blessed reference behaviour.
|
||||||
|
- SciMesh integration tests once Phase 3 is in.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- OCM 1.1 spec: <https://cs3org.github.io/OCM-API/>
|
||||||
|
- Nextcloud OCM docs (implementation quirks): <https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/federated_cloud_sharing.html>
|
||||||
|
- Reva (Go reference impl): <https://github.com/cs3org/reva>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Phase A of the federation-identity rename (see docs/plan/ocm.md § Schema rename).
|
||||||
|
--
|
||||||
|
-- Prepares the identity model to accommodate OCM + IndieAuth + OpenID Federation
|
||||||
|
-- alongside today's single-strategy OIDC surface. Nothing about VALUES changes in
|
||||||
|
-- this migration; the rename is pure and the new `federation_kind` column is
|
||||||
|
-- backfilled deterministically from the presence of the existing OIDC columns.
|
||||||
|
--
|
||||||
|
-- Value semantics (`federation_issuer` still holds the display-name label today,
|
||||||
|
-- not the true `iss` URL) are corrected in Phase B — see plan doc. Ship this
|
||||||
|
-- first so the schema shape is stable before that value migration.
|
||||||
|
--
|
||||||
|
-- Rollback: DROP the federation_kind column; the RENAMEd columns keep working
|
||||||
|
-- with pre-rename code paths because their values are untouched.
|
||||||
|
|
||||||
|
ALTER TABLE auth.users RENAME COLUMN oidc_provider TO federation_issuer;
|
||||||
|
ALTER TABLE auth.users RENAME COLUMN oidc_subject TO federation_subject;
|
||||||
|
|
||||||
|
ALTER TABLE auth.users ADD COLUMN federation_kind TEXT
|
||||||
|
CHECK (federation_kind IN ('magic_link', 'ocm', 'oidc'));
|
||||||
|
|
||||||
|
-- Backfill: existing rows with a federation_issuer set are all OIDC-linked
|
||||||
|
-- (only the OIDC flow populated the old oidc_provider column). Anything NULL
|
||||||
|
-- stays NULL — local / password / magic-link users don't get a kind.
|
||||||
|
UPDATE auth.users
|
||||||
|
SET federation_kind = 'oidc'
|
||||||
|
WHERE federation_issuer IS NOT NULL;
|
||||||
|
|
||||||
|
-- Swap the anti-duplicate uniqueness. Old index was keyed on
|
||||||
|
-- (oidc_provider, oidc_subject); new one includes federation_kind so OIDC
|
||||||
|
-- and OCM (and future IndieAuth / OpenID Federation) principals stay
|
||||||
|
-- distinct even if their (issuer, subject) tuples collide across kinds.
|
||||||
|
DROP INDEX IF EXISTS auth.idx_users_oidc;
|
||||||
|
CREATE UNIQUE INDEX idx_users_federation
|
||||||
|
ON auth.users(federation_kind, federation_issuer, federation_subject)
|
||||||
|
WHERE federation_kind IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.federation_kind IS
|
||||||
|
'Trust chain that owns this identity: oidc | ocm | magic_link. NULL for pure local users. See docs/plan/ocm.md and docs/plan/federated-login.md.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.federation_issuer IS
|
||||||
|
'Authority that mints the subject id. OIDC: iss URL. OCM: peer domain. Magic-link: NULL. NOTE: legacy rows may still hold the OXICLOUD_OIDC_PROVIDER_NAME display label; Phase B of the federation-identity rename backfills these to real issuer URLs.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.federation_subject IS
|
||||||
|
'Stable identifier for this user within the federation_issuer. OIDC: sub claim (stable per RFC 7519 §4.1.2). OCM: federated address (protocol has no separate stable id in 1.1). Magic-link: NULL.';
|
||||||
@@ -110,8 +110,9 @@ mod tests {
|
|||||||
"alice@example.com".to_string(),
|
"alice@example.com".to_string(),
|
||||||
Some("alice".to_string()),
|
Some("alice".to_string()),
|
||||||
None,
|
None,
|
||||||
None,
|
None, // federation_kind
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
@@ -152,8 +153,9 @@ mod tests {
|
|||||||
"bob@example.com".to_string(),
|
"bob@example.com".to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None, // federation_kind
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ pub struct AdminUserSummaryDto {
|
|||||||
pub is_external: bool,
|
pub is_external: bool,
|
||||||
/// TRUE when the user has a server-verifiable password on file
|
/// TRUE when the user has a server-verifiable password on file
|
||||||
/// (`password_hash IS NOT NULL`). The admin table uses this
|
/// (`password_hash IS NOT NULL`). The admin table uses this
|
||||||
/// alongside `oidc_provider` and `opaque_registered` to render
|
/// alongside `federation_issuer` and `opaque_registered` to render
|
||||||
/// the user's full capability set: a `password` chip lights up
|
/// the user's full capability set: a `password` chip lights up
|
||||||
/// here, an OIDC provider name renders the SSO badge, an
|
/// here, an OIDC provider name renders the SSO badge, an
|
||||||
/// envelope-on-file flips the OPAQUE chip. A user with none of
|
/// envelope-on-file flips the OPAQUE chip. A user with none of
|
||||||
@@ -171,7 +171,7 @@ impl From<UserListEntry> for AdminUserSummaryDto {
|
|||||||
storage_used_bytes: entry.storage_used_bytes,
|
storage_used_bytes: entry.storage_used_bytes,
|
||||||
last_login_at: entry.last_login_at,
|
last_login_at: entry.last_login_at,
|
||||||
active: entry.active,
|
active: entry.active,
|
||||||
auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()),
|
auth_provider: entry.federation_issuer.unwrap_or_else(|| "local".to_string()),
|
||||||
is_external: entry.is_external,
|
is_external: entry.is_external,
|
||||||
has_password: entry.has_password,
|
has_password: entry.has_password,
|
||||||
opaque_registered: entry.opaque_registered,
|
opaque_registered: entry.opaque_registered,
|
||||||
@@ -208,7 +208,7 @@ impl From<User> for UserDto {
|
|||||||
last_login_at: p.last_login_at,
|
last_login_at: p.last_login_at,
|
||||||
active: p.active,
|
active: p.active,
|
||||||
// Some(provider) moves the String; None still allocates "local".
|
// Some(provider) moves the String; None still allocates "local".
|
||||||
auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()),
|
auth_provider: p.federation_issuer.unwrap_or_else(|| "local".to_string()),
|
||||||
image: p.image,
|
image: p.image,
|
||||||
can_edit_image,
|
can_edit_image,
|
||||||
is_external: p.is_external,
|
is_external: p.is_external,
|
||||||
|
|||||||
@@ -194,10 +194,14 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
|||||||
/// Changes a user's password
|
/// Changes a user's password
|
||||||
async fn change_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DomainError>;
|
async fn change_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Finds a user by OIDC provider + subject pair
|
/// Finds a user by federation (issuer, subject) pair. Historically
|
||||||
async fn get_user_by_oidc_subject(
|
/// called for OIDC lookups (the only federation kind in-tree at rename
|
||||||
|
/// time); after Phase B/C of the federation-identity rename the
|
||||||
|
/// caller passes the true `iss` URL rather than a display label. See
|
||||||
|
/// `docs/plan/ocm.md § Schema rename` for the transition.
|
||||||
|
async fn get_user_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
provider: &str,
|
issuer: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
) -> Result<User, DomainError>;
|
) -> Result<User, DomainError>;
|
||||||
|
|
||||||
@@ -383,12 +387,12 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
|||||||
|
|
||||||
/// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`:
|
/// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`:
|
||||||
/// revoke every session belonging to the user identified by
|
/// revoke every session belonging to the user identified by
|
||||||
/// `(oidc_provider, oidc_subject)`. Returns the affected user id, or
|
/// `(federation_issuer, federation_subject)`. Returns the affected
|
||||||
/// `None` if we don't know that user.
|
/// user id, or `None` if we don't know that user.
|
||||||
async fn revoke_user_sessions_by_oidc_subject(
|
async fn revoke_user_sessions_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
oidc_provider: &str,
|
issuer: &str,
|
||||||
oidc_subject: &str,
|
subject: &str,
|
||||||
) -> Result<Option<Uuid>, DomainError>;
|
) -> Result<Option<Uuid>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -560,8 +560,9 @@ impl AuthApplicationService {
|
|||||||
dto.email.clone(),
|
dto.email.clone(),
|
||||||
dto.username.clone(),
|
dto.username.clone(),
|
||||||
password_hash,
|
password_hash,
|
||||||
None,
|
None, // federation_kind: local password registration
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
role,
|
role,
|
||||||
quota,
|
quota,
|
||||||
false,
|
false,
|
||||||
@@ -662,8 +663,9 @@ impl AuthApplicationService {
|
|||||||
email,
|
email,
|
||||||
Some(username.clone()),
|
Some(username.clone()),
|
||||||
Some(password_hash),
|
Some(password_hash),
|
||||||
None,
|
None, // federation_kind: setup admin is local
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
role,
|
role,
|
||||||
quota,
|
quota,
|
||||||
false,
|
false,
|
||||||
@@ -1523,7 +1525,7 @@ impl AuthApplicationService {
|
|||||||
.await?
|
.await?
|
||||||
} else if let Some(sub) = claims.sub.as_ref() {
|
} else if let Some(sub) = claims.sub.as_ref() {
|
||||||
self.session_storage
|
self.session_storage
|
||||||
.revoke_user_sessions_by_oidc_subject(&provider_name, sub)
|
.revoke_user_sessions_by_federation_subject(&provider_name, sub)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -2810,8 +2812,9 @@ impl AuthApplicationService {
|
|||||||
email,
|
email,
|
||||||
Some(dto.username.clone()),
|
Some(dto.username.clone()),
|
||||||
Some(password_hash),
|
Some(password_hash),
|
||||||
None,
|
None, // federation_kind: admin-created external, no federation link yet
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
@@ -2821,8 +2824,9 @@ impl AuthApplicationService {
|
|||||||
email,
|
email,
|
||||||
Some(dto.username.clone()),
|
Some(dto.username.clone()),
|
||||||
Some(password_hash),
|
Some(password_hash),
|
||||||
None,
|
None, // federation_kind: admin-created local user
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
role,
|
role,
|
||||||
quota,
|
quota,
|
||||||
false,
|
false,
|
||||||
@@ -3395,7 +3399,7 @@ impl AuthApplicationService {
|
|||||||
// 5. Look up existing user by OIDC subject
|
// 5. Look up existing user by OIDC subject
|
||||||
let user = match self
|
let user = match self
|
||||||
.user_storage
|
.user_storage
|
||||||
.get_user_by_oidc_subject(&provider_name, &claims.sub)
|
.get_user_by_federation_subject(&provider_name, &claims.sub)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mut existing_user) => {
|
Ok(mut existing_user) => {
|
||||||
@@ -3511,6 +3515,14 @@ impl AuthApplicationService {
|
|||||||
oidc_email,
|
oidc_email,
|
||||||
Some(username.clone()),
|
Some(username.clone()),
|
||||||
None,
|
None,
|
||||||
|
Some(crate::domain::entities::user::FederationKind::Oidc),
|
||||||
|
// TODO Phase B: `provider_name` still carries the
|
||||||
|
// OXICLOUD_OIDC_PROVIDER_NAME display label instead
|
||||||
|
// of the true `iss` URL. Lazy-rebind on subsequent
|
||||||
|
// logins converts the row (see docs/plan/ocm.md
|
||||||
|
// § Rename PR — Phase B). First-login value is the
|
||||||
|
// label for backwards compatibility with existing
|
||||||
|
// rows.
|
||||||
Some(provider_name.clone()),
|
Some(provider_name.clone()),
|
||||||
Some(claims.sub.clone()),
|
Some(claims.sub.clone()),
|
||||||
role,
|
role,
|
||||||
|
|||||||
@@ -225,12 +225,18 @@ impl MagicLinkInviteService {
|
|||||||
|
|
||||||
// External users are created without a username or password.
|
// External users are created without a username or password.
|
||||||
// `password_hash IS NULL` is the canonical no-password marker.
|
// `password_hash IS NULL` is the canonical no-password marker.
|
||||||
|
//
|
||||||
|
// federation_kind stays None in Phase A of the federation-identity
|
||||||
|
// rename — magic-link externals get their `federation_kind` stamp
|
||||||
|
// in a future PR when the invite handler is refactored to opt into
|
||||||
|
// the composable federation model. Behaviour is unchanged today.
|
||||||
let mut user = User::new(
|
let mut user = User::new(
|
||||||
normalised_email.to_string(),
|
normalised_email.to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None, // federation_kind
|
||||||
None,
|
None, // federation_issuer
|
||||||
|
None, // federation_subject
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
@@ -1006,15 +1012,21 @@ mod tests {
|
|||||||
use crate::domain::entities::user::{User, UserRole};
|
use crate::domain::entities::user::{User, UserRole};
|
||||||
|
|
||||||
fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User {
|
fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User {
|
||||||
let (provider, subject) = match oidc {
|
use crate::domain::entities::user::FederationKind;
|
||||||
Some((p, s)) => (Some(p.to_string()), Some(s.to_string())),
|
let (kind, issuer, subject) = match oidc {
|
||||||
None => (None, None),
|
Some((p, s)) => (
|
||||||
|
Some(FederationKind::Oidc),
|
||||||
|
Some(p.to_string()),
|
||||||
|
Some(s.to_string()),
|
||||||
|
),
|
||||||
|
None => (None, None, None),
|
||||||
};
|
};
|
||||||
User::new(
|
User::new(
|
||||||
"test@example.com".to_string(),
|
"test@example.com".to_string(),
|
||||||
None,
|
None,
|
||||||
password.map(str::to_string),
|
password.map(str::to_string),
|
||||||
provider,
|
kind,
|
||||||
|
issuer,
|
||||||
subject,
|
subject,
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
0,
|
0,
|
||||||
|
|||||||
+121
-34
@@ -28,6 +28,62 @@ impl std::fmt::Display for UserRole {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trust chain that owns a user's identity. NULL on `auth.users`
|
||||||
|
/// means "pure local user, no external federation" — the common case for
|
||||||
|
/// password/OPAQUE accounts.
|
||||||
|
///
|
||||||
|
/// See `docs/plan/ocm.md § Identity & auth model` for the full model
|
||||||
|
/// including the `(federation_kind, federation_issuer, federation_subject)`
|
||||||
|
/// composite identity key.
|
||||||
|
///
|
||||||
|
/// - `Oidc`: authenticated via an OIDC provider; `federation_issuer` =
|
||||||
|
/// the id_token `iss` claim, `federation_subject` = the `sub` claim.
|
||||||
|
/// Note: legacy rows may still hold the OXICLOUD_OIDC_PROVIDER_NAME
|
||||||
|
/// display label as `federation_issuer` until Phase B of the rename
|
||||||
|
/// backfills them to real issuer URLs.
|
||||||
|
/// - `Ocm`: OCM 1.1 federated principal (future — `docs/plan/ocm.md`).
|
||||||
|
/// `federation_issuer` = peer domain, `federation_subject` = federated
|
||||||
|
/// address (e.g. `alice@remote.example.com`).
|
||||||
|
/// - `MagicLink`: external invitee whose only auth is mailbox
|
||||||
|
/// possession. Both `federation_issuer` and `federation_subject`
|
||||||
|
/// remain NULL for this kind — the identity is the local `email`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FederationKind {
|
||||||
|
MagicLink,
|
||||||
|
Ocm,
|
||||||
|
Oidc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationKind {
|
||||||
|
/// Canonical DB / wire spelling — matches the CHECK constraint on
|
||||||
|
/// `auth.users.federation_kind`.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FederationKind::MagicLink => "magic_link",
|
||||||
|
FederationKind::Ocm => "ocm",
|
||||||
|
FederationKind::Oidc => "oidc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the DB / wire spelling. Returns `None` for anything other
|
||||||
|
/// than the three canonical values — the CHECK constraint on the
|
||||||
|
/// column and the enum variants are the source of truth.
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"magic_link" => Some(FederationKind::MagicLink),
|
||||||
|
"ocm" => Some(FederationKind::Ocm),
|
||||||
|
"oidc" => Some(FederationKind::Oidc),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FederationKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Authorization-relevant account flags, fetched without the heavyweight
|
/// Authorization-relevant account flags, fetched without the heavyweight
|
||||||
/// profile columns. The full user row drags `image` along — a data URI of
|
/// profile columns. The full user row drags `image` along — a data URI of
|
||||||
/// up to 512 KiB — which per-request guards (`require_internal_user`,
|
/// up to 512 KiB — which per-request guards (`require_internal_user`,
|
||||||
@@ -72,8 +128,12 @@ pub struct User {
|
|||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
last_login_at: Option<DateTime<Utc>>,
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
active: bool,
|
active: bool,
|
||||||
oidc_provider: Option<String>,
|
/// Which trust chain minted the (issuer, subject) pair. `None` on
|
||||||
oidc_subject: Option<String>,
|
/// pure local users (password/OPAQUE). See [`FederationKind`] for the
|
||||||
|
/// three federation flavours.
|
||||||
|
federation_kind: Option<FederationKind>,
|
||||||
|
federation_issuer: Option<String>,
|
||||||
|
federation_subject: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
/// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM
|
/// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM
|
||||||
/// federated). FALSE = storage-owning internal user. Hooks that
|
/// federated). FALSE = storage-owning internal user. Hooks that
|
||||||
@@ -161,8 +221,9 @@ pub struct UserParts {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub oidc_provider: Option<String>,
|
pub federation_kind: Option<FederationKind>,
|
||||||
pub oidc_subject: Option<String>,
|
pub federation_issuer: Option<String>,
|
||||||
|
pub federation_subject: Option<String>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub is_external: bool,
|
pub is_external: bool,
|
||||||
pub given_name: Option<String>,
|
pub given_name: Option<String>,
|
||||||
@@ -190,8 +251,9 @@ impl User {
|
|||||||
updated_at,
|
updated_at,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider,
|
federation_kind,
|
||||||
oidc_subject,
|
federation_issuer,
|
||||||
|
federation_subject,
|
||||||
image,
|
image,
|
||||||
is_external,
|
is_external,
|
||||||
given_name,
|
given_name,
|
||||||
@@ -213,8 +275,9 @@ impl User {
|
|||||||
updated_at,
|
updated_at,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider,
|
federation_kind,
|
||||||
oidc_subject,
|
federation_issuer,
|
||||||
|
federation_subject,
|
||||||
image,
|
image,
|
||||||
is_external,
|
is_external,
|
||||||
given_name,
|
given_name,
|
||||||
@@ -230,7 +293,7 @@ impl User {
|
|||||||
///
|
///
|
||||||
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
||||||
/// external). The credential slots and the `is_external` marker are all
|
/// external). The credential slots and the `is_external` marker are all
|
||||||
/// caller-controlled — what makes a user "OIDC" is `oidc_subject =
|
/// caller-controlled — what makes a user "OIDC" is `federation_subject =
|
||||||
/// Some(_)`, what makes them "external" is `is_external = true`. There
|
/// Some(_)`, what makes them "external" is `is_external = true`. There
|
||||||
/// are no hidden sentinel values; an absent credential is `None`.
|
/// are no hidden sentinel values; an absent credential is `None`.
|
||||||
///
|
///
|
||||||
@@ -239,7 +302,7 @@ impl User {
|
|||||||
/// * `username` — optional handle (2-64 chars, no `@`)
|
/// * `username` — optional handle (2-64 chars, no `@`)
|
||||||
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
|
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
|
||||||
/// the user has no password yet (magic-link or OIDC bootstrap)
|
/// the user has no password yet (magic-link or OIDC bootstrap)
|
||||||
/// * `oidc_provider`, `oidc_subject` — both `Some` when the user is
|
/// * `federation_issuer`, `federation_subject` — both `Some` when the user is
|
||||||
/// linked to an external IdP, both `None` otherwise
|
/// linked to an external IdP, both `None` otherwise
|
||||||
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
|
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
|
||||||
/// `users_external_not_admin` DB CHECK constraint)
|
/// `users_external_not_admin` DB CHECK constraint)
|
||||||
@@ -251,8 +314,9 @@ impl User {
|
|||||||
email: String,
|
email: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
oidc_provider: Option<String>,
|
federation_kind: Option<FederationKind>,
|
||||||
oidc_subject: Option<String>,
|
federation_issuer: Option<String>,
|
||||||
|
federation_subject: Option<String>,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
storage_quota_bytes: i64,
|
storage_quota_bytes: i64,
|
||||||
is_external: bool,
|
is_external: bool,
|
||||||
@@ -280,12 +344,26 @@ impl User {
|
|||||||
"External users must have storage_quota_bytes = 0".to_string(),
|
"External users must have storage_quota_bytes = 0".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// OIDC linkage is all-or-nothing: both provider and subject set,
|
// Federation linkage is all-or-nothing: both issuer and subject set,
|
||||||
// or neither. The DB has a UNIQUE index on (provider, subject)
|
// or neither. The DB has a UNIQUE index on
|
||||||
// WHERE both non-NULL; partial state would corrupt that.
|
// (federation_kind, federation_issuer, federation_subject) WHERE
|
||||||
if oidc_provider.is_some() != oidc_subject.is_some() {
|
// federation_kind IS NOT NULL; partial state would corrupt that.
|
||||||
|
//
|
||||||
|
// For kinds that don't carry an authority-issued subject
|
||||||
|
// (MagicLink today — identity is the local email), both fields
|
||||||
|
// stay None even when `federation_kind` is set. The check below
|
||||||
|
// only fires on inconsistent partial state.
|
||||||
|
if federation_issuer.is_some() != federation_subject.is_some() {
|
||||||
return Err(UserError::ValidationError(
|
return Err(UserError::ValidationError(
|
||||||
"oidc_provider and oidc_subject must both be set or both be None".to_string(),
|
"federation_issuer and federation_subject must both be set or both be None".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// If either field is set, federation_kind MUST also be set — the
|
||||||
|
// schema keys anti-duplicate on the composite (kind, issuer,
|
||||||
|
// subject) and a NULL kind would defeat the uniqueness.
|
||||||
|
if federation_issuer.is_some() && federation_kind.is_none() {
|
||||||
|
return Err(UserError::ValidationError(
|
||||||
|
"federation_kind is required when federation_issuer/subject are set".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +380,9 @@ impl User {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
last_login_at: None,
|
last_login_at: None,
|
||||||
active: true,
|
active: true,
|
||||||
oidc_provider,
|
federation_kind,
|
||||||
oidc_subject,
|
federation_issuer,
|
||||||
|
federation_subject,
|
||||||
image: None,
|
image: None,
|
||||||
is_external,
|
is_external,
|
||||||
given_name: None,
|
given_name: None,
|
||||||
@@ -355,8 +434,9 @@ impl User {
|
|||||||
updated_at,
|
updated_at,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider: None,
|
federation_kind: None,
|
||||||
oidc_subject: None,
|
federation_issuer: None,
|
||||||
|
federation_subject: None,
|
||||||
image: None,
|
image: None,
|
||||||
// `from_data` is the minimal-args reconstruction path used by
|
// `from_data` is the minimal-args reconstruction path used by
|
||||||
// tests and JWT-claim-based principal hydration (which doesn't
|
// tests and JWT-claim-based principal hydration (which doesn't
|
||||||
@@ -387,8 +467,9 @@ impl User {
|
|||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
last_login_at: Option<DateTime<Utc>>,
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
active: bool,
|
active: bool,
|
||||||
oidc_provider: Option<String>,
|
federation_kind: Option<FederationKind>,
|
||||||
oidc_subject: Option<String>,
|
federation_issuer: Option<String>,
|
||||||
|
federation_subject: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
is_external: bool,
|
is_external: bool,
|
||||||
given_name: Option<String>,
|
given_name: Option<String>,
|
||||||
@@ -413,8 +494,9 @@ impl User {
|
|||||||
updated_at,
|
updated_at,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider,
|
federation_kind,
|
||||||
oidc_subject,
|
federation_issuer,
|
||||||
|
federation_subject,
|
||||||
image,
|
image,
|
||||||
is_external,
|
is_external,
|
||||||
given_name,
|
given_name,
|
||||||
@@ -556,12 +638,16 @@ impl User {
|
|||||||
format!("{}…", &self.id.to_string()[..8])
|
format!("{}…", &self.id.to_string()[..8])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oidc_provider(&self) -> Option<&str> {
|
pub fn federation_kind(&self) -> Option<FederationKind> {
|
||||||
self.oidc_provider.as_deref()
|
self.federation_kind
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oidc_subject(&self) -> Option<&str> {
|
pub fn federation_issuer(&self) -> Option<&str> {
|
||||||
self.oidc_subject.as_deref()
|
self.federation_issuer.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn federation_subject(&self) -> Option<&str> {
|
||||||
|
self.federation_subject.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn image(&self) -> Option<&str> {
|
pub fn image(&self) -> Option<&str> {
|
||||||
@@ -739,7 +825,7 @@ impl User {
|
|||||||
|
|
||||||
/// Returns true if this is an OIDC-only user (no password)
|
/// Returns true if this is an OIDC-only user (no password)
|
||||||
pub fn is_oidc_user(&self) -> bool {
|
pub fn is_oidc_user(&self) -> bool {
|
||||||
self.oidc_provider.is_some()
|
self.federation_issuer.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true iff this user has any non-magic-link authentication
|
/// Returns true iff this user has any non-magic-link authentication
|
||||||
@@ -748,7 +834,7 @@ impl User {
|
|||||||
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
|
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
|
||||||
/// flag widens the policy at the service layer (`magic_link_eligibility`).
|
/// flag widens the policy at the service layer (`magic_link_eligibility`).
|
||||||
pub fn has_login_credential(&self) -> bool {
|
pub fn has_login_credential(&self) -> bool {
|
||||||
self.password_hash.is_some() || self.oidc_subject.is_some()
|
self.password_hash.is_some() || self.federation_subject.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the password hash. The new password must be hashed externally
|
/// Set the password hash. The new password must be hashed externally
|
||||||
@@ -891,9 +977,10 @@ mod tests {
|
|||||||
Utc::now(),
|
Utc::now(),
|
||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
None,
|
None, // federation_kind
|
||||||
None,
|
None, // federation_issuer
|
||||||
None,
|
None, // federation_subject
|
||||||
|
None, // image
|
||||||
false,
|
false,
|
||||||
given.map(str::to_string),
|
given.map(str::to_string),
|
||||||
family.map(str::to_string),
|
family.map(str::to_string),
|
||||||
|
|||||||
@@ -81,16 +81,16 @@ pub trait SessionRepository: Send + Sync + 'static {
|
|||||||
async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult<Vec<Uuid>>;
|
async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult<Vec<Uuid>>;
|
||||||
|
|
||||||
/// Revokes every session belonging to the user identified by
|
/// Revokes every session belonging to the user identified by
|
||||||
/// `(oidc_provider, oidc_subject)`.
|
/// `(federation_issuer, federation_subject)`.
|
||||||
///
|
///
|
||||||
/// Fallback path for the Back-Channel Logout handler when the IdP
|
/// Fallback path for the Back-Channel Logout handler when the IdP
|
||||||
/// omits `sid` from the logout_token — coarser than sid-based
|
/// omits `sid` from the logout_token — coarser than sid-based
|
||||||
/// revocation (kills the user's other devices too). Returns the
|
/// revocation (kills the user's other devices too). Returns the
|
||||||
/// user id of the affected account, or `None` if no matching user.
|
/// user id of the affected account, or `None` if no matching user.
|
||||||
async fn revoke_user_sessions_by_oidc_subject(
|
async fn revoke_user_sessions_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
oidc_provider: &str,
|
issuer: &str,
|
||||||
oidc_subject: &str,
|
subject: &str,
|
||||||
) -> SessionRepositoryResult<Option<Uuid>>;
|
) -> SessionRepositoryResult<Option<Uuid>>;
|
||||||
|
|
||||||
/// Deletes expired sessions
|
/// Deletes expired sessions
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub struct UserListEntry {
|
|||||||
pub storage_used_bytes: i64,
|
pub storage_used_bytes: i64,
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub oidc_provider: Option<String>,
|
pub federation_issuer: Option<String>,
|
||||||
pub is_external: bool,
|
pub is_external: bool,
|
||||||
/// TRUE when `auth.users.password_hash IS NOT NULL` — user has a
|
/// TRUE when `auth.users.password_hash IS NOT NULL` — user has a
|
||||||
/// server-verifiable password on file (legacy or admin-set).
|
/// server-verifiable password on file (legacy or admin-set).
|
||||||
@@ -53,7 +53,7 @@ pub struct UserListEntry {
|
|||||||
/// envelope): a fully-migrated user carries BOTH — password for
|
/// envelope): a fully-migrated user carries BOTH — password for
|
||||||
/// the fallback / operator flows, envelope for the actual login.
|
/// the fallback / operator flows, envelope for the actual login.
|
||||||
/// A user with `has_password = false AND !opaque_registered AND
|
/// A user with `has_password = false AND !opaque_registered AND
|
||||||
/// oidc_provider IS NULL` is passwordless — the only path in is
|
/// federation_issuer IS NULL` is passwordless — the only path in is
|
||||||
/// via magic-link (or, for externals, whatever grant they hold).
|
/// via magic-link (or, for externals, whatever grant they hold).
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
/// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user
|
/// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user
|
||||||
@@ -173,10 +173,10 @@ pub trait UserRepository: Send + Sync + 'static {
|
|||||||
/// Deletes a user
|
/// Deletes a user
|
||||||
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
||||||
|
|
||||||
/// Finds a user by OIDC provider + subject pair
|
/// Finds a user by federation (issuer, subject) pair.
|
||||||
async fn get_user_by_oidc_subject(
|
async fn get_user_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
provider: &str,
|
issuer: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
) -> UserRepositoryResult<User>;
|
) -> UserRepositoryResult<User>;
|
||||||
|
|
||||||
|
|||||||
@@ -390,12 +390,17 @@ impl SessionRepository for SessionPgRepository {
|
|||||||
|
|
||||||
/// Back-Channel Logout fallback — the IdP omitted `sid` in the
|
/// Back-Channel Logout fallback — the IdP omitted `sid` in the
|
||||||
/// logout_token, so we revoke every session belonging to the user
|
/// logout_token, so we revoke every session belonging to the user
|
||||||
/// identified by (oidc_provider, oidc_subject). Users are looked up
|
/// identified by (federation_issuer, federation_subject). Users are
|
||||||
/// through the existing auth.users columns.
|
/// looked up through auth.users; the query is federation-kind-agnostic
|
||||||
async fn revoke_user_sessions_by_oidc_subject(
|
/// today (matches any user regardless of federation_kind) — an OCM
|
||||||
|
/// notification arriving here would find only OIDC users because that's
|
||||||
|
/// the only path that writes federation_issuer today, but the schema
|
||||||
|
/// admits OCM rows too, so this method may see multi-kind matches once
|
||||||
|
/// OCM lands. Restrict by federation_kind then if that becomes ambiguous.
|
||||||
|
async fn revoke_user_sessions_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
oidc_provider: &str,
|
issuer: &str,
|
||||||
oidc_subject: &str,
|
subject: &str,
|
||||||
) -> SessionRepositoryResult<Option<Uuid>> {
|
) -> SessionRepositoryResult<Option<Uuid>> {
|
||||||
// Two-step: look up the user first (deterministic error class if
|
// Two-step: look up the user first (deterministic error class if
|
||||||
// the user is unknown), then revoke. Combining into a single
|
// the user is unknown), then revoke. Combining into a single
|
||||||
@@ -404,11 +409,11 @@ impl SessionRepository for SessionPgRepository {
|
|||||||
let user_row = sqlx::query(
|
let user_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id FROM auth.users
|
SELECT id FROM auth.users
|
||||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
WHERE federation_issuer = $1 AND federation_subject = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(oidc_provider)
|
.bind(issuer)
|
||||||
.bind(oidc_subject)
|
.bind(subject)
|
||||||
.fetch_optional(&*self.pool)
|
.fetch_optional(&*self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -432,9 +437,9 @@ impl SessionRepository for SessionPgRepository {
|
|||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "oidc.backchannel_logout_by_sub",
|
event = "federation.backchannel_logout_by_sub",
|
||||||
oidc_provider = %oidc_provider,
|
federation_issuer = %issuer,
|
||||||
oidc_subject = %oidc_subject,
|
federation_subject = %subject,
|
||||||
user_id = %user_id,
|
user_id = %user_id,
|
||||||
revoked_count = result.rows_affected(),
|
revoked_count = result.rows_affected(),
|
||||||
"👮🏻♂️ OIDC backchannel-logout revoked all user sessions by sub"
|
"👮🏻♂️ OIDC backchannel-logout revoked all user sessions by sub"
|
||||||
@@ -586,12 +591,12 @@ impl SessionStoragePort for SessionPgRepository {
|
|||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke_user_sessions_by_oidc_subject(
|
async fn revoke_user_sessions_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
oidc_provider: &str,
|
issuer: &str,
|
||||||
oidc_subject: &str,
|
subject: &str,
|
||||||
) -> Result<Option<Uuid>, DomainError> {
|
) -> Result<Option<Uuid>, DomainError> {
|
||||||
SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject)
|
SessionRepository::revoke_user_sessions_by_federation_subject(self, issuer, subject)
|
||||||
.await
|
.await
|
||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,12 +282,13 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role,
|
id, username, email, password_hash, role,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject,
|
||||||
|
image, is_external,
|
||||||
given_name, family_name, email_verified_at,
|
given_name, family_name, email_verified_at,
|
||||||
preferred_locale, notify_on_share, ui_preferences
|
preferred_locale, notify_on_share, ui_preferences
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
|
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
@@ -303,8 +304,9 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.updated_at())
|
.bind(user_clone.updated_at())
|
||||||
.bind(user_clone.last_login_at())
|
.bind(user_clone.last_login_at())
|
||||||
.bind(user_clone.is_active())
|
.bind(user_clone.is_active())
|
||||||
.bind(user_clone.oidc_provider())
|
.bind(user_clone.federation_kind().map(|k| k.as_str()))
|
||||||
.bind(user_clone.oidc_subject())
|
.bind(user_clone.federation_issuer())
|
||||||
|
.bind(user_clone.federation_subject())
|
||||||
.bind(user_clone.image())
|
.bind(user_clone.image())
|
||||||
.bind(user_clone.is_external())
|
.bind(user_clone.is_external())
|
||||||
.bind(user_clone.given_name())
|
.bind(user_clone.given_name())
|
||||||
@@ -339,7 +341,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -370,8 +372,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -391,7 +396,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -422,8 +427,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -443,7 +451,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -474,8 +482,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -512,7 +523,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, is_external,
|
federation_kind, federation_issuer, federation_subject, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE id = ANY($1)
|
WHERE id = ANY($1)
|
||||||
@@ -544,8 +555,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
None, // image — not projected (notification-recipient path)
|
None, // image — not projected (notification-recipient path)
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -693,7 +707,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -731,8 +745,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -780,13 +797,13 @@ impl UserRepository for UserPgRepository {
|
|||||||
// pays. `has_password` on the password_hash column tells
|
// pays. `has_password` on the password_hash column tells
|
||||||
// the admin table whether a server-verifiable password is
|
// the admin table whether a server-verifiable password is
|
||||||
// on file; combined with the two OPAQUE flags and
|
// on file; combined with the two OPAQUE flags and
|
||||||
// oidc_provider, the SPA derives the full "capability
|
// federation_issuer, the SPA derives the full "capability
|
||||||
// set" per user (password / OPAQUE / SSO / passwordless).
|
// set" per user (password / OPAQUE / SSO / passwordless).
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, username, email, role::text,
|
id, username, email, role::text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
last_login_at, active, oidc_provider, is_external,
|
last_login_at, active, federation_issuer, is_external,
|
||||||
(password_hash IS NOT NULL) AS has_password,
|
(password_hash IS NOT NULL) AS has_password,
|
||||||
(opaque_envelope IS NOT NULL) AS opaque_registered,
|
(opaque_envelope IS NOT NULL) AS opaque_registered,
|
||||||
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
|
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
|
||||||
@@ -815,7 +832,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_used_bytes,
|
storage_used_bytes,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider,
|
federation_issuer,
|
||||||
is_external,
|
is_external,
|
||||||
has_password,
|
has_password,
|
||||||
opaque_registered,
|
opaque_registered,
|
||||||
@@ -833,7 +850,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
storage_used_bytes,
|
storage_used_bytes,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
active,
|
active,
|
||||||
oidc_provider,
|
federation_issuer,
|
||||||
is_external,
|
is_external,
|
||||||
has_password,
|
has_password,
|
||||||
opaque_registered,
|
opaque_registered,
|
||||||
@@ -856,7 +873,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -894,8 +911,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -999,7 +1019,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
@@ -1034,8 +1054,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -1068,7 +1091,7 @@ impl UserRepository for UserPgRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Finds a user by OIDC provider + subject pair
|
/// Finds a user by OIDC provider + subject pair
|
||||||
async fn get_user_by_oidc_subject(
|
async fn get_user_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
@@ -1079,11 +1102,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, oidc_subject, image, is_external,
|
federation_kind, federation_issuer, federation_subject, image, is_external,
|
||||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
|
||||||
ui_preferences
|
ui_preferences
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
WHERE federation_issuer = $1 AND federation_subject = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(provider)
|
.bind(provider)
|
||||||
@@ -1110,8 +1133,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("updated_at"),
|
row.get("updated_at"),
|
||||||
row.get("last_login_at"),
|
row.get("last_login_at"),
|
||||||
row.get("active"),
|
row.get("active"),
|
||||||
row.get("oidc_provider"),
|
row.get::<Option<String>, _>("federation_kind")
|
||||||
row.get("oidc_subject"),
|
.as_deref()
|
||||||
|
.and_then(crate::domain::entities::user::FederationKind::parse),
|
||||||
|
row.get("federation_issuer"),
|
||||||
|
row.get("federation_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
row.get("given_name"),
|
row.get("given_name"),
|
||||||
@@ -1367,12 +1393,12 @@ impl UserStoragePort for UserPgRepository {
|
|||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_by_oidc_subject(
|
async fn get_user_by_federation_subject(
|
||||||
&self,
|
&self,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
UserRepository::get_user_by_oidc_subject(self, provider, subject)
|
UserRepository::get_user_by_federation_subject(self, provider, subject)
|
||||||
.await
|
.await
|
||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
@@ -1441,7 +1467,7 @@ mod integration_tests {
|
|||||||
id, username, email, password_hash, role,
|
id, username, email, password_hash, role,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
created_at, updated_at, last_login_at, active,
|
||||||
oidc_provider, is_external
|
federation_issuer, is_external
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, NULL, $4::auth.userrole,
|
$1, $2, $3, NULL, $4::auth.userrole,
|
||||||
$5, 0,
|
$5, 0,
|
||||||
@@ -1515,7 +1541,7 @@ mod integration_tests {
|
|||||||
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
|
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
|
||||||
assert_eq!(page[1].username, None);
|
assert_eq!(page[1].username, None);
|
||||||
assert!(page[1].is_external);
|
assert!(page[1].is_external);
|
||||||
assert_eq!(page[1].oidc_provider.as_deref(), Some("integration-idp"));
|
assert_eq!(page[1].federation_issuer.as_deref(), Some("integration-idp"));
|
||||||
|
|
||||||
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
|
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user