V1 Web PWA · Journey

Journey: Tenant bulk member invite (CSV + SCIM)

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 3 specs verdict: partial· 2026-05-29
9sections7 minread1table

On this page
Journey at a glance
ActorsPlatform or tenant-console operatorIdP provisioning clientSigned-in inviteeRoster auditor
Automation3 E2E spec(s) · verdict: partial· 2026-05-29

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Inspect the rendered tenant register"] s2["2. Export, invite, or add one member"] s3["3. Accept the one-time invite"] s4["4. Provision through SCIM"] s5["5. Compare the three persistence boundaries"] s6["6. Reconcile available evidence"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 click s1 href "#1-inspect-the-rendered-tenant-register" click s2 href "#2-export-invite-or-add-one-member" click s3 href "#3-accept-the-one-time-invite" click s4 href "#4-provision-through-scim" click s5 href "#5-compare-the-three-persistence-boundaries" click s6 href "#6-reconcile-available-evidence" s6 --> v(["verdict: partial"]) click v href "../results/tenant-bulk-member-invite-scim.html" style v stroke:#d6a531,color:#d6a531

The title currently spans three independent provisioning seams rather than one bulk member funnel. /operator/tenant exports its rendered register and offers single Invite/Add dialogs; there is no CSV import. SCIM Users, Groups, and Bulk provide a real IdP-facing Postgres lifecycle, but they do not populate the tenant invite/member store that the operator register reads. The signed-in invitee can accept a one-time token, while email, SSO/MFA, onboarding, and first-login audit remain outside that proven path.

Personas#

  • Platform or tenant-console operator — exports the visible register, creates one pending invite, or directly seats one existing user id.
  • IdP provisioning client — calls SCIM Users, Groups, or ordered Bulk with a configured server-to-server bearer and tenant header.
  • Signed-in invitee — accepts a specific invite id/token and becomes an active member.
  • Roster auditor — must distinguish the invite-store projection from the separate SCIM/Postgres identity projection.

Pre-conditions#

  • /operator/tenant is available to the operator and renders the Pacific Theological fixture register plus any invite-store live rows.
  • Invite/Add mutation accepts admin:* or admin:workspace:tenant-console; tenant-console cockpit reads separately require tenant:admin:<tenantId> or tenant:admin:*.
  • Deployable BFF startup requires the Postgres-backed invite/member/seat snapshot and hydrates it before traffic. The default seat pool is 25 for each previously unseen tenant.
  • SCIM requires OSHUN_SCIM_BEARER_TOKEN of at least 32 characters, a valid tenant header, and Postgres for writes. Without Postgres, list reads may show a safe fixture/empty response while mutation returns SCIM-shaped 503.

Steps#

1. Inspect the rendered tenant register#

The page combines eight fixture rows with live invites/members from GET /v1/tenant-console/members. Live mutation refresh changes the appended rows and exposes a refresh-state marker. The “247 members · 192 active” heading and five KPI cells remain fixture values; they are not recomputed from the live seat pool.

2. Export, invite, or add one member#

The three visible actions have exact boundaries:

Action Shipped outcome
Export CSV Browser-generated file from the currently rendered fixture + live rows
Invite One pending invite, SHA-256 token hash, raw token shown once
Add member One immediate active member and one allocated seat

There is no Import CSV control, file parser, row validation preview, staged batch, or partial-commit UI. The exported CSV is useful as a report/template, not the first half of a round-trip.

Invite accepts an RFC-email-shaped address and optional free-text role id. It does not enforce a tenant email-domain allowlist, plan, or reviewer-approved role template. Creating an invite does not reserve a seat; seat capacity is checked when that invite is accepted. Add member accepts only a user id in the UI and defaults its roles, even though the API can accept role ids. Invite and Add acknowledge only after the candidate snapshot is saved. The bulk auth executor likewise awaits the membership save after account provisioning. A failed save returns controlled 503, retains the prior roster/pool state, and does not append a success audit.

3. Accept the one-time invite#

The operator manually copies the raw token; no URL or outbound email is created. A signed-in customer opens /tenant/invite?inviteId=…&token=…&tenant=…&next=/onboarding and explicitly clicks Accept. The BFF verifies pending state, expiry, constant-time token hash, and seat capacity, creates the active member, and atomically snapshots the accepted invite/member/pool state before responding. The accepted invitation is linked internally to the exact subject for privacy fanout, but that subject link is never returned by the invite view.

The page then exposes a “Continue to onboarding” link. Acceptance does not mint a session, run SSO/MFA, complete onboarding, or record tenant.member_first_login/tenant.member_first_action.

4. Provision through SCIM#

An IdP can create/read/patch/tombstone Users and Groups and issue ordered Users/Groups operations through /v1/scim/v2/Bulk. Group patches propagate memberships to the Postgres SCIM user projection; create, suspend, deactivate, reactivate, group lifecycle, and membership sync emit tenant.scim_provision.* audit events. A canonical identity mirror also tracks provision/update/tombstone/revive state.

SCIM does not call tenantInviteStore.addMember, allocate from its seat pool, or create its invitation records. Consequently, the operator page's live member read does not automatically reflect an IdP-created SCIM user.

5. Compare the three persistence boundaries#

CSV export has no server write. Invite/Add/Accept use the durable tenant invite snapshot. SCIM uses Postgres SCIM users/groups and the canonical identity mirror. Their audit rows share the admin event store, but a shared audit prefix does not make the member projections or seat accounting identical.

6. Reconcile available evidence#

tenant.member_invite and tenant.member_create are recorded for operator mutations. Their new success details retain operational record ids and categorical role/state/status data but omit raw target email and user id. SCIM lifecycle events are recorded for IdP mutations. Invite acceptance itself currently records no tenant audit event, and no shipped sequence continues through role assignment, callback, email, first login, or first action.

Post-conditions#

  • CSV export contains the page's currently rendered rows, with no import state.
  • Invite creates a pending token record; Add creates an active seated member; Accept turns one pending invite into an active seated member.
  • SCIM Users/Groups/Bulk persist and audit a separate IdP-facing lifecycle when Postgres is present.
  • Raw invite and SCIM secrets are not returned by later read models.
  • Profile export finds exact memberships and accepted invites after an account email change; signed erasure removes them, releases seats, and preserves an exact-user resurrection fence.
  • No single batch count, shared seat ledger, register refresh, or end-to-end first-login chain joins all three seams.

Failure modes#

  • CSV round-trip implied — export exists but Import CSV does not.
  • Seat timing surprise — many pending invites can be created; capacity can fail only when invitees accept.
  • Snapshot write failure — Invite/Add/Accept and bulk-auth membership return controlled 503 without publishing candidate roster or seat state.
  • Replica race/retention — ordering is per process over one whole snapshot, with no distributed compare-and-swap; terminal invite rows have no general time-based purge.
  • No domain/role policy validation — the single invite route checks email syntax, not tenant allowed domains or approved role templates.
  • Token delivery gap — the UI shows a raw token, not a branded email or complete invite URL; operators must transfer it out of band.
  • Accept audit gap — successful acceptance changes durable membership but does not append tenant.member_first_login or another acceptance event.
  • Projection split — SCIM writes do not appear in the tenant invite-store register or consume its seats.
  • Static KPI drift — live rows append while the headline/member metrics stay at fixture values.
  • SCIM without Postgres — mutations fail closed with 503; list behavior alone must not be treated as persistence proof.
  • Bulk partial failure — SCIM Bulk returns per-operation results, but there is no operator preview/rollback joining them to the tenant register.

E2E coverage#

  • apps/oshun/web/e2e/tenant-member-invite-scim.spec.ts covers CSV export and real Postgres SCIM Users/Groups/Bulk lifecycle, membership propagation, tombstones, canonical mirror, auth gates, and audit read-back.
  • apps/oshun/web/e2e/tenant-member-actions.spec.ts covers browser Invite/Add, cockpit refresh, audit rows, signed-in acceptance, second-use/wrong-token, email validation, and seat capacity.
  • apps/oshun/web/e2e/tenant-invite-role-bff.spec.ts proves API role ids persist on the pending invite and protects the admin scope.
  • The final isolated exact-source run of the two affected invite/member specs passed 12/12 in Chromium, including public omission of the accepted-subject privacy link.
  • Coverage depth: partial. Each implemented seam has strong real-infra proof. CSV import, SCIM→member-register/seat projection, outbound invite, SSO/MFA/onboarding, acceptance audit, and aggregate reconciliation are not implemented end to end.

Per-view files touched#

Cross-references#

  • tenant-onboard.md — the single-invite customer boundary in detail.
  • tenant-oneroster-roster-sync.md — a separate institutional roster projection.
  • tenant-audit-log-investigation.md — platform audit explorer.
  • Sources: TenantMemberRegister.tsx, TenantMemberActions.tsx, TenantInviteAcceptPanel.tsx, admin-tenant-invitations.ts, tenant-invite-store.ts, tenant-console-reads.ts, and the SCIM block in apps/oshun/bff/src/routes/domain-stubs.ts.

Open questions#

  • Which real CSV import route owns parsing, dry-run conflicts, idempotency, partial commit, and row-level recovery?
  • Should SCIM provisioned users feed the same membership/seat projection as Invite/Add, or should the UI explicitly reconcile two directories?
  • What email/link service receives the raw invite token without exposing it to operator copy/paste?
  • Which audit event records acceptance, role activation, first login, and first tenant action without conflating those states?
  • How should live member counts and seat capacity replace the fixed fixture KPI band on /operator/tenant?