← All stories

BRANCH · ef-013-collaborators

Event Collaborators & Roles

EF-013 Persona: Event organizer (admin) Stage: Event setup → access control Roots in: event-setup Matrix=Not Ready

Per-event collaborator invitations with scoped roles: organizer, read-only, support, check-in staff, assistant. The organizer adds collaborators by email; collaborators receive an invitation email; on acceptance, collaborator gains the scoped role limited to that event. Story enforces full audit, revocation cascades, and the role-permission contract via ui-permissions-matrix. Tier-3 tightening: collaborator edit conflicts now reference ui-conflict-resolution-modal as the shared resolution contract.

Preconditions

  • Inherits event-setup trunk: organizer is on the event setup hub for an event they own.
  • Tenant is on a plan that allows event collaborators (some plans cap at owner-only).
  • Email-sending channel configured for invitation delivery.

Happy path

  1. Organizer opens Event Settings → Collaborators.

    Page renders existing collaborators (paginated ui-data-table with per-row email, role, accepted-at, last-active). "Invite collaborator" CTA opens a modal.

  2. Organizer enters email + selects role.

    Form: email field, role select (organizer | read-only | support | check-in-staff | assistant), optional note (free text, sent in invitation email). Submit.

  3. Server creates invitation, sends email.

    collaborator_invitations row created (status=pending), HMAC-signed invite token, expires in 7 days. Invitation email sent via notification outbox.

  4. Invitee accepts.

    Click email link → /collab/accept?token=... → if invitee already has a Voyage account, prompt to sign in then auto-accept. If new, prompt to create account first then auto-accept. On accept, invitation transitions pending→accepted, collaborator_grants row created with role + event_id, audit log row written.

  5. Collaborator can now use the event.

    Role's permissions apply event-scoped only. The event appears in their event picker. The ui-permissions-matrix at Event Settings → Collaborators shows the collaborator with their granted abilities (computed from role).

  6. Organizer revokes a collaborator.

    Per-row "Remove" with ui-destructive-confirmation. On confirm, collaborator_grants row soft-deleted (kept for audit), active sessions invalidated within 60s (sessions reference the grant row's status). Audit log row written.

Failure modes

Permission denied at the right boundary

Trigger: read-only collaborator tries to add another collaborator.

Read-only role lacks collaborators:add ability. Server returns 403; UI hides the "Invite" CTA. Harness: stub read-only role, attempt POST invite, assert 403, assert no invitation row created.

Cross-event isolation

Trigger: collaborator with role on event A tries to access event B (same tenant).

Grants are event-scoped. Cross-event API access returns 404 (anti-probing — same as not-found). Harness: stub event-A grant, attempt event-B GET, assert 404.

Cross-tenant invitation forgery

Trigger: attacker forges an invitation token for a tenant they don't belong to.

Token signature is per-tenant HMAC. Forged token fails signature check; returns 404. Harness: forge token signed with wrong secret, assert 404.

Token expired

Trigger: invitee opens invitation link 8 days after sent (token expires after 7).

Server returns 410 INVITATION_EXPIRED. UI shows "This invitation has expired. Ask the organizer to resend." Organizer can resend from the admin queue (creates a new token, marks old as superseded). Harness: stub past expires_at, accept attempt, 410.

Token replay after acceptance

Trigger: invitee already accepted; same link clicked again (e.g., from email forward).

Token is one-time-use. Already-accepted token returns 410 INVITATION_ALREADY_USED. UI shows "This invitation was already accepted." If the invitee is signed in, deep-link to the event. Harness: accept twice, second returns 410.

Role escalation attempt

Trigger: invitee modifies the role parameter in the accept request payload.

Role is server-side from the invitation row, not from the accept request. The accept endpoint ignores any role in the payload. Harness: accept with payload role=organizer when invitation says read-only, assert grant.role === read-only.

Revocation cascade — active sessions

Trigger: collaborator currently has the admin shell open in another browser when their grant is revoked.

Sessions reference the grant row. Within 60s of grant.status=revoked, the next API call from the revoked session returns 403 GRANT_REVOKED with UI "Your access to this event was revoked." Harness: revoke grant, dispatch API call from active session within 60s, assert 403.

Invitation to an existing collaborator

Trigger: organizer invites the same email twice.

If the email already has an active grant on this event, return 409 ALREADY_COLLABORATOR with current role; UI shows "This person is already a collaborator (Read-Only). Update their role instead?" link to existing row. If pending invitation exists, return 409 INVITATION_PENDING with resend option. Harness: invite same email twice, second returns 409.

Audit log per state change

Trigger: lifecycle event — invite created, accepted, revoked, role changed.

Each transition writes an audit log row with operator_id (who acted), subject_id (collaborator), event_id, action, timestamp. Audit can reconstruct the full grant timeline. Harness: cycle through invite/accept/revoke, assert 3 audit rows in correct order.

Two organizers concurrent: simultaneous role change

Trigger: two organizers both edit the same collaborator's role within seconds.

Optimistic concurrency via grant.version. Stale write returns 409 VERSION_CONFLICT with conflict modal showing the current state. Last-actor's intent is preserved or rejected, never silent overwrite. Harness: stub version mismatch on PATCH, assert 409 + conflict modal visible.

Plan downgrade with active collaborators

Trigger: tenant downgrades to owner-only plan; existing event has 3 collaborators.

Existing grants preserved (no destructive plan changes). Owner can no longer add new collaborators (412 PRECONDITION_FAILED with "Upgrade to add collaborators"). Existing collaborators continue working until owner explicitly revokes. Audit row at downgrade time captures the snapshot. Harness: stub plan downgrade, assert existing grants active, new invites return 412.

Permission matrix display matches actual server enforcement

Trigger: ui-permissions-matrix shows "read events: yes" but server actually allows "edit events" too (drift).

The matrix is computed from a single source-of-truth role→ability mapping. Displayed permissions must match server enforcement bit-for-bit. Harness: enumerate every (role, ability) cell on the matrix; for each, attempt the server action; assert each cell's display matches the server's 200/403 response.

Stable test attributes

data-testWherePurpose
collaborators-pageEvent Settings → CollaboratorsTab in event setup hub
collaborators-listExisting collaboratorsui-data-table; per-row role + accepted-at
collaborators-invite-ctaPage headerVisible only when role has collaborators:add ability
collaborators-invite-modalInvite modalForm fields: email, role, note
collaborators-role-selectInside invite modal5 options: organizer, read-only, support, check-in-staff, assistant
collaborators-permissions-matrixPageui-permissions-matrix showing role→ability mapping
collaborators-revoke-buttonPer rowui-destructive-confirmation
collaborators-already-collaboratorVisible after 409 ALREADY_COLLABORATOR"Update their role instead?" link
collaborators-version-conflict-modalVisible after 409 VERSION_CONFLICTTwo-organizers concurrent
invitation-accept-page/collab/accept public-ish pageInvitee acceptance UI
invitation-expired-messageVisible after 410 INVITATION_EXPIRED"Ask organizer to resend" copy
invitation-already-used-messageVisible after 410 INVITATION_ALREADY_USED"Already accepted" copy
plan-downgrade-warningSettings if plan downgraded"Existing collaborators preserved; new invites blocked" copy

Agent test plan

Probe list
- read-only-cannot-invite: stub read-only role, POST invite, 403 + invite CTA hidden
- cross-event-404: stub event-A grant, GET event-B, 404
- cross-tenant-token-forgery-404: forge token, accept, 404
- token-expired-410: stub past expires_at, accept, 410 + expired-message visible
- token-already-used-410: accept twice, second 410 + already-used-message visible
- role-escalation-ignored: accept with payload role=organizer when invite said read-only, grant.role === read-only
- revocation-cascades-active-sessions: revoke + dispatch API from active session within 60s, 403 GRANT_REVOKED
- duplicate-invitation-409: invite same email twice, second 409 + already-collaborator-message visible
- audit-log-per-state-change: invite/accept/revoke cycle, 3 audit rows in order
- two-organizers-concurrent-409: stub version mismatch, conflict-modal visible
- plan-downgrade-preserves-existing: existing grants active after downgrade
- plan-downgrade-blocks-new-invites: post-downgrade invite returns 412
- matrix-display-matches-server-enforcement: enumerate cells, assert display == server behavior
- email-prefilled-on-acceptance: invitation email pre-fills the invitee's email field on signin
- audit-log-references-event-and-tenant: every audit row carries event_id + tenant_id