Skip to content

feat: admin↔organization membership + active-org switcher#240

Merged
jeroenrinzema merged 7 commits into
feat/invitesfrom
feat/org-membership
Jun 24, 2026
Merged

feat: admin↔organization membership + active-org switcher#240
jeroenrinzema merged 7 commits into
feat/invitesfrom
feat/org-membership

Conversation

@jeroenrinzema

@jeroenrinzema jeroenrinzema commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces many-to-many admin ↔ organization membership with an active organization that scopes each session. This is the follow-up to #227 (email-keyed invites) and lifts the same-org invite guard added there: accepting an invite into another org's project now makes the admin a member of that organization.

Note

Stacked on #227 (feat/invites). Review/merge that first — this PR's base is feat/invites, so the diff here is membership-only. It will retarget to main once #227 merges.

Why

admins.organization_id was a single FK — an admin belonged to exactly one org. That made cross-org project sharing impossible to represent cleanly (the reason #227 had to reject inviting an email belonging to another org). This adds the membership table that the codebase already anticipated (// TODO: when the org×admin relation is implemented…).

Changes

Schema

  • organization_members(organization_id, admin_id, role, …, deleted_at) — backfilled one row per existing admin from admins.organization_id
  • admins.active_organization_id — backfilled to the home org
  • admins.organization_id kept as the home org and dual-written (no destructive change; a later PR can drop it)

Backend

  • OrganizationMembersStore: AddMember (upsert + revive), RemoveMember, GetMember, IsMember, ListOrganizationsForAdmin; SetActiveOrganization on the admins store
  • WithJWT resolves the active org and re-validates membership on every request (falls back to home org → any membership), so a revoked membership or stale active-org can't leak access
  • Shared provisionMembership helper used by all four provisioning paths (Clerk first-login + webhook, basic auth, admin-create) — also fixes a pre-existing bug where admin-create wrote no org RBAC tuple at all
  • New endpoints: GET /api/admin/organizations, POST /api/admin/active-organization
  • ListAdmins + the three OrganizationID != gates now use membership; DeleteAdmin removes the membership (+ org tuples) instead of globally deleting a possibly multi-org admin
  • Invite accept adds the invitee as an org member; Feat/invites #227's create-time same-org guard removed

Frontend

  • adminOrganizations API + AdminOrganization type
  • OrganizationSwitcher in the sidebar (hidden when <2 orgs); reloads on switch so org-scoped data refetches cleanly

Testing

  • go build OSS + -tags enterprise, gofmt clean
  • New DB-backed organization_members_test.go (testcontainers / real Postgres) covering upsert/revive, membership gates, ListAdmins, cross-org membership, and active-org switching
  • Existing internal/store/management and internal/http/auth/providers suites pass (the provider suite exercises the modified provisioning paths)

Notes / follow-ups

  • The active-org resolution in WithJWT is the hottest, most safety-critical path — extra reviewer eyes welcome there.
  • A later PR can drop admins.organization_id once all readers move to membership + active_organization_id.

Introduce many-to-many admin/organization membership so an admin can
belong to several organizations, with an active organization scoping
their session. This lifts the same-org invite restriction added in the
invites rework: accepting an invite into another org's project now makes
the admin a member of that organization.

Schema:
- organization_members(organization_id, admin_id, role, soft-delete),
  backfilled one row per existing admin from admins.organization_id
- admins.active_organization_id, backfilled to the home organization
- admins.organization_id is kept as the home org and dual-written

Backend:
- OrganizationMembersStore (AddMember upsert/revive, RemoveMember,
  GetMember, IsMember, ListOrganizationsForAdmin); SetActiveOrganization
  on the admins store
- WithJWT resolves the active organization and validates membership on
  every request, falling back to the home org / any membership
- shared provisionMembership helper writes the membership + RBAC tuples
  across all provisioning paths (Clerk first-login + webhook, basic auth,
  admin create — the last previously wrote no org tuple at all)
- GET /api/admin/organizations and POST /api/admin/active-organization
- ListAdmins, GetAdmin, UpdateAdmin gate on membership instead of the
  single-org equality; DeleteAdmin now removes the membership (+ org
  tuples) rather than globally deleting a possibly multi-org admin
- invite accept adds the invitee as an org member; create guard lifted

Frontend:
- adminOrganizations API (mine / setActive) and AdminOrganization type
- OrganizationSwitcher in the sidebar (hidden when <2 orgs), reloads on
  switch so org-scoped data is refetched cleanly
Covers AddMember upsert/revive, IsMember/GetMember, RemoveMember,
ListOrganizationsForAdmin, SetActiveOrganization, and the new
membership-gated ListAdmins, against real Postgres via testcontainers.
…-closed active-org resolution

CreateAdmin's existing-admin branch no longer calls UpdateAdmin: adding an
already-registered person to an organization is now purely an org-scoped
membership grant and never overwrites that admin's GLOBAL email/name/role.
Previously an Org A owner could rewrite an Org B admin's global identity and
role (privilege escalation), since this feature lifts the same-org invite guard.

Both the new-admin and existing-admin paths now go through a shared
provisionMembership helper that does all DB writes (admin insert + membership
upsert) in one transaction and writes RBAC tuples only after commit, mirroring
the invite-accept ordering. This removes the previous three-independent-ops
sequence that could strand an admin with no membership or a membership with no
tuples.

resolveActiveOrganization no longer swallows DB errors: a real failure (not a
clean "not a member") now propagates so the request fails, instead of failing
OPEN onto the home org and bypassing the revoked-membership check on a hot
security path.

SetActiveOrganization maps DB errors to a clean 500 (never a 403 that could mask
a transient failure as "not a member", never leaking the raw error) and the new
ListMyOrganizations/SetActiveOrganization handlers guard a nil actor. The
is_active derivation from the resolved active org is documented so it is not
"fixed" later.
…dundant unique index

Add ReconcileAdminOrganizations: after a membership is soft-deleted it clears an
active_organization_id that pointed at the removed org and re-points the home
organization_id to a remaining membership when the removed org was the home org.
DeleteAdmin now runs RemoveMember + this reconciliation in one transaction, so
correctness no longer relies solely on the read-time fallback in
resolveActiveOrganization.

Migration: drop the partial UNIQUE index (WHERE deleted_at IS NULL) that
duplicated the table-level UNIQUE (organization_id, admin_id). AddMember's bare
ON CONFLICT (organization_id, admin_id) can only arbitrate against the
non-partial constraint, so the partial index was redundant and made the arbiter
ambiguous. Keeping a single all-rows UNIQUE makes soft-delete-then-re-add revive
the same row unambiguously.
…bility, reconcile

- resolveActiveOrganization: valid active membership is used; a revoked active
  org falls back to a current membership; a DB error propagates (does not fail
  open onto the home org).
- SetActiveOrganization rejects a non-member with 403 and leaves the stored
  active org unchanged (IDOR guard).
- CreateAdmin for an existing cross-org admin grants Org A membership at the
  requested role without mutating the admin's global email/name/role or their
  Org B membership.
- ReconcileAdminOrganizations: clears a dangling active org, re-points a home
  org that referenced the removed org, and leaves the home pointer intact when
  the removed org was the only membership.

Also documents the organization-switcher is_active fallback so it is not
"fixed" later.
# Conflicts:
#	internal/http/controllers/v1/management/invites_enterprise.go
Matches the fix already on main (#268). The campaigns.provider_id column
was removed; this enterprise test still set Campaign.ProviderID, breaking
go vet -tags enterprise for the consumer package on this branch.
@jeroenrinzema jeroenrinzema merged commit d8263e9 into feat/invites Jun 24, 2026
6 checks passed
@jeroenrinzema jeroenrinzema deleted the feat/org-membership branch June 24, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant