Overview
A WorkspaceMembership is the row that says "this user belongs to this workspace, with this role, with these limits." It's the joining table between UserProfile and Workspace and the unit of access control across the entire product: every API call resolves the caller's WorkspaceMembership for the workspace in the URL prefix, and every RLS policy filters rows by the same workspace_id.
Members live at /ws/{slug}/settings/members. The list view supports filtering by role, status, and expiry; the detail panel exposes per-member audit, role history, and revocation. This article covers the full lifecycle — invite, accept, manage, expire, revoke — plus break-glass procedures for emergencies.
Why it exists
Membership is the most security-sensitive surface in the product. Mis-managing it leaks tenant data; over-managing it adds friction. The model is intentionally separated from the user identity (UserProfile) so the same person can be a member of many workspaces with different roles and limits in each, with one identity, one OIDC subject, one notification preferences page.
Key concepts
WorkspaceMembership model
WorkspaceMembership (workspace, user, role, custom_role (nullable FK to CustomRole), expires_at (nullable), created_at, created_by, revoked_at (nullable), revoked_by, revoke_reason, team).
- role — one of the five built-ins (
owner | admin | approver | engineer | viewer) — see Roles and permissions. - custom_role — optional override; when set, the custom role's permission set replaces the built-in. The
rolefield still serves as a "tier hint" for default-layout selection. - expires_at — optional UTC datetime after which the membership is automatically downgraded to
viewer(or revoked outright, configurable). - team — string label used for grouping in DORA-by-team views and dashboards.
Invitation flow
A new member is added via the invite flow, never by direct WorkspaceMembership.objects.create():
- Admin opens
/ws/{slug}/settings/members/invite, types the email, picks a role (and optionalexpires_at). - The system creates an
Invitationrow (email,role,token,expires_at_token(default 7 days),invited_by), emits amember.invitedevent viadispatch_event(T0.3), and writes avigilo.audit.invite_sentaudit row (T0.2). - The invitee receives an email with a signed token link. Clicking the link routes them through the Keycloak OIDC flow (creating a
UserProfileif first-time) and then accepts the invitation by creating theWorkspaceMembershipand emittingmember.accepted. - If the invitee already has a
UserProfilein another workspace, no new account is created — the membership is added under their existing identity.
Invitations expire on expires_at_token. Expired invitations are cleaned by a Celery beat task daily; the inviter receives a notification so they can re-send.
Bulk invite via CSV
For onboarding many users at once (WA.9), admins can upload a CSV with columns email,role,team,expires_at (last two optional). The system validates each row (email format, role in the allowed set, expires_at in the future), shows a preview with row-level error highlighting, and on confirm creates one Invitation per valid row in a transaction. Up to 500 rows per upload; larger lists must be split. Each invite is audited and dispatched the same as a single invite, so downstream automation (Slack member channel, IT provisioning webhook) fires identically.
Bulk role change
Owners and admins can select multiple members in the list view and apply a single role change in one operation (T1.11). The bulk-action confirmation surfaces a diff ("3 engineers → approver, 1 viewer → engineer") and writes one audit row per affected member. The member.role_changed event fires per member so playbook evaluators see them as distinct events.
Per-member detail panel
Clicking a member opens a side panel with:
- Identity summary (name, email, OIDC subject, MFA status).
- Current role and custom role.
- Role history (every change with old/new and the actor).
- Active sessions (Keycloak-linked).
- Per-row actions (PR1) — Change role, Set expiry, Revoke, Reset MFA, Send password-reset email.
Time-bound roles with auto-downgrade
Setting expires_at on a membership schedules a Celery beat task (WB.10) that runs hourly: any membership where expires_at < now() and role > viewer is automatically downgraded to viewer (or revoked, depending on Workspace.settings.expiry_action). The downgrade writes an audit row and dispatches member.expired. Useful for contractors, audits, vendors, and seasonal staff.
Break-glass emergency role
The break-glass flow (WB.11) lets an admin grant a one-shot elevated role (typically owner for one hour) outside normal approval cadence — for incident commanders who need temporary admin rights mid-incident. The grant requires a typed justification, writes a high-visibility audit row, fires a member.break_glass_granted webhook (often wired to a Slack #security channel), and schedules an auto-revoke at the chosen expiry. Every break-glass use is reviewed by owners weekly via the dedicated Settings → Members → Break-glass log tab.
Common workflows
Invite one member
- Settings → Members → Invite, type email, pick role (e.g. engineer), optionally pick team and expiry.
- Click Send invite. The invitee receives an email; the invitation appears in the Pending filter.
- On accept, the membership row is created and the invitation row marked
accepted.
Bulk invite from CSV
- Click Invite → Upload CSV, drag the file.
- Fix any red-highlighted rows in the preview (typos, role aliases). Re-upload or correct in place.
- Click Send all valid. The system creates invites in a transaction; partial failures are reported per row.
Bulk role change
- Filter the member list to the target set, select with checkboxes.
- Click Bulk action → Change role, pick the target role, confirm the diff.
- The actor and reason are audited per member.
Grant break-glass
- Open the member detail, click Break glass.
- Pick the elevated role and expiry (max 24 hours), type a justification.
- The grant is applied immediately, audited, dispatched, and queued for auto-revoke.
Permissions
- Owners can invite/revoke any role including other owners, grant break-glass, and change SCIM provisioning settings.
- Admins can invite/revoke up to admin, change roles, grant break-glass.
- Approvers / engineers can read the member list, no edits.
- Viewers see only their own membership row.
All membership mutations emit a member.* event for webhook subscribers and write an audit row.
Troubleshooting
Invitation email never arrived — Check the Pending filter: the invite may have been created but the SMTP layer failed. See SMTP configuration for diagnostic. Re-send from the invite row's action menu.
Member can sign in but sees "no workspace access" — The WorkspaceMembership may be revoked_at != null or expires_at < now(). Check the per-member detail for the current status.
Bulk CSV upload preview shows all rows red — Header row missing or column names wrong. The required header is email,role with optional team,expires_at. Lowercase, comma-separated.
Auto-downgrade did not fire — The hourly Celery beat task is paused or the worker is down. Check Flower; manually run python manage.py expire_memberships --workspace {slug}.
Break-glass auto-revoke did not fire — The scheduled task per grant is stored as a Celery eta job; if the worker restarted, the job is replayed from the persistent broker — confirm Redis is the broker (not in-memory). If the grant is overdue, manually revoke from the member detail.