> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0024 org member management

# 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
(`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
  `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).
