Skip to main content

ADR-0024: Account-portal org member management (invite allowlist + last-owner invariant)

Status: Accepted · 2026-07-16

Context

ADR-0017 built the Account portal with a single-owner org: registering created a cp_account and one org_members row (the owner). It named “org member management” as the remaining gap — a real commercial org needs more than one person to see licenses, downloads, and support. This ADR adds teammates without weakening the portal’s guards or its content boundary.

Decisions

1. Two roles, and an org can never be orphaned

Roles stay owner | member (no new admin tier — it would widen the Membership union used across the portal for marginal benefit). Members can view the org (members, licenses, downloads, support); owners additionally manage members. The load-bearing invariant is enforced in the service layer, with tests: an org always keeps at least one owner — the last owner cannot be removed or demoted (countOwners(accountId) <= 1 ⇒ 409). So no org is ever left with no one who can administer it.

2. Invitations are an email allowlist that auto-accepts on sign-in

An owner invites by email into their own org (org_invites, keyed by (account_id, lowercased email), upsert-idempotent). On every sign-in path (issueSessionacceptInvitesForEmail) a user’s pending invites are accepted: they’re added to each inviting org and the invite row is deleted. This works whether or not the invitee already has a portal account — it composes with Clerk JIT provisioning (idp mode provisions the user by verified email, then accepts invites) and with dev-mode register/login. An invite only ever adds someone to the inviter’s own org, so it grants no cross-tenant access. addMember is ON CONFLICT DO NOTHING, so accepting an invite never downgrades an existing higher role.

3. Every route is membership-guarded; only owners mutate; CSRF on writes

GET …/members requires membership (a non-member gets 404, no existence leak). invite, role, DELETE member, DELETE invite require the actor be an owner of the accountId in the URL (403 otherwise) and carry the session CSRF token. The target of every mutation is bound to the same accountId via getMembership(targetUserId, accountId), so :userId / :email path params can’t reach a member of a different org (no IDOR). Each mutation records a control event (member_invited, member_removed, member_role_changed, member_invite_revoked) whose safeDetail carries only the role or "ok" — never the raw email — keeping the vendor audit log PII-light.

4. Content boundary preserved

The portal already legitimately holds emails and display names; member management adds no customer-governed content. Invites, membership, and events stay within the portal identity plane — nothing from any deployment’s scanned data is involved.

Consequences

  • An org owner can bring teammates in by email; they land already a member the first time they sign in, with no separate “accept” step.
  • The single-owner limitation from ADR-0017 is lifted without new cross-tenant or privilege-escalation surface, and without an org ever becoming un-administrable.
  • Out of scope (future): an org-scoped invite cap / rate limit for abuse, an admin tier if finer delegation is needed, and surfacing invites in the invitee’s own “pending invitations” view before first sign-in.

Verification

account/members.test.ts (11 tests): store idempotency + case-insensitive invite accept; listMembersForAccount join + countOwners; the full rule set (owner-only guards, invalid-email/already-member rejections, last-owner cannot be demoted/removed, non-member target 404, membership-guarded listing, revoke-invite ownership); and route integration in dev mode (invite → teammate signs up → auto-joined → member cannot invite → stranger 404 → promote/remove → last-owner removal refused with 409, CSRF required). Full server suite (331 tests) passes; typecheck + lint clean. Reviewed adversarially (tenant-isolation / invite-identity / content-boundary lenses with per-finding verification).