this make OIDC compliant with the invariant binding (issuer and subject) admin can now rename their provider without breaking clarifing federation_kind: report the kind of federation wired not the allowed login method hybryd login method are still allowed
27 KiB
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 = truepassword_hash = NULL(enforced by existingusers_external_no_storageCHECK 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 mintsub=1234for two completely different people.oidc— theissclaim from the id_token (alsodiscovery.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 localemail, no external authority involved).- Fixes an existing bug. Today
oidc_providerstores the human-readableOXICLOUD_OIDC_PROVIDER_NAMElabel (e.g. "SSO", "MockSSO"), not the issuer URL. Consequences of the current shape:- Admin renaming
OXICLOUD_OIDC_PROVIDER_NAMEsilently orphans every existing OIDC user — new lookups key on the new label, stored rows have the old one. - 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. - Multi-IdP futures (multi-realm, multi-tenant) would suffer cross-issuer subject collision if two IdPs share a display name.
- Admin renaming
- Display name becomes a separate concern — either derived from a
peer_configs.issuer → display_namelookup, or stored alongside as a denormalizedfederation_display_namecolumn (admin- editable, no identity impact). Not part of the identity key.
federation_subject— the remote id string. Stability:oidc— thesubclaim, 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/RevaopaqueUserId) but they aren't in base OCM. Treatfederation_subjectas 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 localemailcolumn).
- Composite UNIQUE index
(federation_kind, federation_issuer, federation_subject)WHEREfederation_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.
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_issuerrenamed fromoidc_provider,federation_subjectrenamed fromoidc_subject) — the SQL block above. - Application code DUAL-WRITES on OIDC login: still writes the display
label to
federation_issuer(unchanged semantics) AND stampsfederation_kind = 'oidc'. Reads unchanged. - Ships safely as a pure rename + kind-backfill. Rollback = drop the
federation_kindcolumn; 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_issuerdoes not equal the id_token'siss, UPDATE it (with an audit lineevent="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
issis 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 withhttp://orhttps://and contains/. - Same lazy-rebind logic covers BCL: if a BCL notification's
issmatches a user'sfederation_subjectbut 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 byfederation_kindand how many still hold display-label-shapedfederation_issuervalues.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_urlis 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_NAMEmid-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.
- Single-IdP deployment (typical): if the current OIDC config's
- Log each mapping decision to
auditfor 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_issuervalue doesn't equal the id_token'sissclaim, 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
isson 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_issuervalue that looks like a display label (heuristic: doesn't start withhttp://orhttps://, 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):
Userentity fields (oidc_provider→federation_issueretc.)UserPgRepositorySQL statementsOidcIdClaims → Usermapping inauth_application_service(setsfederation_kind = 'oidc')admin_settings_service's env-override table- Any DTOs that leaked
oidc_providerexternally — checkUserDtobefore the migration in case FE reads the field - Tests
Why rename rather than coexist:
oidc_provideris a misleading name anyway — a value likeMyIdPisn't a "provider" (that's a category), it's the IdP's identity/name.federation_issuerreads 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_userrejection — 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:
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→ markignored, audit). related_share_idpoints 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/sharescreates anocm.inbound_sharesrow (pending). NO role_grant created yet.- User sees pending invitation in
/shared-with-me→Federatedtab (new), plus a badge on AppShell. - Accept → insert
role_grantsrow (subject = local user, resource = a newRemoteShareResourceentry, permission = as offered), POST accept notification to peer, markocm.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_sharestracks 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-domainand shows a channel badge on the resolved recipient (see federated-login.md § Same discovery layer serves invitation). - 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'onrole_grants, resource_id pointing toocm.inbound_shares.id. AuthZ dispatches to a WebDAV-proxy handler for this type. ocm.inbound_shares.stateandocm.outbound_shares.statecolumns hold acceptance metadata.role_grantsstays state-free.- OCM notification handler translates peer messages into local state transitions (create/delete grant, update state column).
Future — multi-federation per user (account linking)
The current schema is strictly 1:1 — one auth.users row carries ONE
(federation_kind, federation_issuer, federation_subject) triple.
Scenarios where this breaks:
- Multi-IdP link — Alice logs in via Google today, later wants to also link her corporate Keycloak (same person, two auth paths).
- OIDC ↔ OCM identity overlap — Bob has a local OIDC-provisioned
account AND colleagues share to him via OCM at
bob@theircloud.example.com. Different trust chains → two rows. His "shared with me" view splits across both identities. - IdP migration — Company switches SSO issuer; the old row is orphaned, new logins mint a fresh row. All grants belong to the ghost.
- Genuine multi-account — some IdPs let one person hold multiple
accounts (different
sub). Different identities per OIDC spec — CORRECTLY two users. This case wants NO merging.
The interesting split: (1)-(3) benefit from merging; (4) must stay separate. Merging is inherently user-driven, not automatic (auto-link would misbehave on case 4). Ship OCM WITHOUT this and add it when a real user asks — every product I've seen shipped account linking as a distinct feature (Nextcloud, GitHub) precisely so it can have its own UX design.
Evolution path (documented so it's not lost, NOT for OCM MVP):
CREATE TABLE auth.user_federations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
federation_kind TEXT NOT NULL,
federation_issuer TEXT NOT NULL,
federation_subject TEXT NOT NULL,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
linked_by UUID REFERENCES auth.users(id),
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (federation_kind, federation_issuer, federation_subject),
UNIQUE (user_id, is_primary) DEFERRABLE
);
Migration: one row per existing auth.users with federation_kind IS NOT NULL, is_primary=true, linked_by=user_id (self-owned).
Then drop the three columns from auth.users after a backward-compat
window (view unions the two representations).
Impact on existing code: anti-duplicate lookup, BCL revocation, JIT
provisioning, lazy rebind — all move to JOINs on user_federations.
The get_user_by_federation_subject repo method is the single
touchpoint; downstream callers don't change.
Ships when demand appears — probably alongside a "Connect another account" settings page. Documented here so the OCM 1:1 shape is a KNOWN LIMITATION, not an oversight.
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_requestswith(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_shareskeeps its federation-specific columns (peer_domain, remote WebDAV URL, sharedSecret) and gains agrant_request_idFK. Accept = flip request toapproved+ createrole_grant. Decline = flip torejected.magic_link_tokenswithresource_kind ≠ NULL(invitation-purpose tokens) gets the same FK. Redeem = flip toapproved+ createrole_grant.role_grantsstays 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)
- Phase 0 — Discovery (
/ocm-provider) + inbound receive + FE listing of received shares (metadata only, no consumption yet). ~1 week. - Phase 1 — Consume remote shares (server-side WebDAV proxy). ~1-2 weeks. Needs a WebDAV client — none exists in the tree today.
- Phase 2 — Outbound sharing (share dialog recognizes
user@remote). ~3-5 days. - Phase 3 — OCM 2.0 invite handshake (ScienceMesh trust flow). ~1 week.
- 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_DOMAINvsemailmatch? 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