feat: admin↔organization membership + active-org switcher#240
Merged
Conversation
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.
deb2ff7 to
a6724fc
Compare
49b41ca to
f876721
Compare
…-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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 isfeat/invites, so the diff here is membership-only. It will retarget tomainonce #227 merges.Why
admins.organization_idwas 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 fromadmins.organization_idadmins.active_organization_id— backfilled to the home orgadmins.organization_idkept 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;SetActiveOrganizationon the admins storeWithJWTresolves 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 accessprovisionMembershiphelper 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 allGET /api/admin/organizations,POST /api/admin/active-organizationListAdmins+ the threeOrganizationID !=gates now use membership;DeleteAdminremoves the membership (+ org tuples) instead of globally deleting a possibly multi-org adminFrontend
adminOrganizationsAPI +AdminOrganizationtypeOrganizationSwitcherin the sidebar (hidden when <2 orgs); reloads on switch so org-scoped data refetches cleanlyTesting
go buildOSS +-tags enterprise,gofmtcleanorganization_members_test.go(testcontainers / real Postgres) covering upsert/revive, membership gates,ListAdmins, cross-org membership, and active-org switchinginternal/store/managementandinternal/http/auth/providerssuites pass (the provider suite exercises the modified provisioning paths)Notes / follow-ups
WithJWTis the hottest, most safety-critical path — extra reviewer eyes welcome there.admins.organization_idonce all readers move to membership +active_organization_id.