ADR-0024: Account-portal org member management (invite allowlist + last-owner invariant)
Status: Accepted · 2026-07-16Context
ADR-0017 built the Account portal with a single-owner org: registering created acp_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 stayowner | 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
(issueSession → acceptInvitesForEmail) 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
admintier 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).