Journey flow#
Generated from the authored steps below — click a node to jump to that section.
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/tenantis available to the operator and renders the Pacific Theological fixture register plus any invite-store live rows.- Invite/Add mutation accepts
admin:*oradmin:workspace:tenant-console; tenant-console cockpit reads separately requiretenant:admin:<tenantId>ortenant: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_TOKENof 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-shaped503.
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
503without 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_loginor 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.tscovers 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.tscovers 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.tsproves 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#
operator/operator-tenant.md— fixture + live member register and its three visible actions.customer/01-onboarding/tenant-invite.md— signed-in acceptance surface.customer/01-onboarding/onboarding.md— link target, not an automatic continuation.operator/operator-audit.md— shared audit read-back.operator/operator-metis.md— adjacent OneRoster projection, not this SCIM member store.
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 inapps/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?