Files
Oxicloud/docs/plan/ocm.md
T
Edouard Vanbelle d8b3f2e026 refactor(oidc): migrate provider into issuer
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
2026-08-08 16:37:45 +02:00

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 = 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.

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.

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.

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.

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 → 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).
  • 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 — 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_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