Documentation Index
Fetch the complete documentation index at: https://handbook.polar.sh/llms.txt
Use this file to discover all available pages before exploring further.
Status: Active
Created: April 2026
Last Updated: May 5, 2026
Summary
Introduce role-based access control for users on a Polar organization. Three roles ship in this iteration —owner, admin, and member — replacing today’s implicit binary where org-level admin capability (member management, organization deletion, payout-account selection, Finance and payouts) is gated by Account.admin_id: the single user who is recorded as the admin on the org’s Polar-side billing Account, today set at org creation.
All three roles are stored on the user–organization membership. admin and member are freely assignable; there is exactly one owner per organization. During rollout, owner is anchored to today’s Account.admin_id via dual-write so the two never disagree. The end state of this work drops Account.admin_id and makes the role table itself the sole source of truth for ownership (a partial unique index enforces the singularity invariant; the KYC/identity-verification gate moves onto role transitions into owner). What owner adds on top of admin is a small set of ownership-tied responsibilities retained for KYC/KYB / UBO reasons: the legal/billing primary contact for the org, the recipient of identity-verification notifications, the sole authority to transfer ownership of the org, and a member-removal exemption (the owner cannot be removed from the org without first transferring ownership).
The change is mostly additive on top of the existing two-layer authorization architecture. Role lives on the user–organization membership and drives a fine-grained permission set that policies consult. Token scopes remain a per-token capability filter; policies AND a token-scope check with a role-permission check at runtime. The Stripe-side PayoutAccount.admin_id is a separate concept and is unaffected by this design.
Goals
- Introduce an
OrganizationRoleenum (owner,admin,member) attached to organization membership. - Move org-level admin authorization off direct
Account.admin_idchecks and onto role permissions evaluated at policy time. - Decommission
Account.admin_id. Once the role-based ownership has been validated in production, drop the column; the role table becomes the sole source of truth for ownership, the KYC/identity-verification gate moves onto role transitions intoowner, and reads that previously joinedAccount.admin_idquery the role instead. - Allow multiple admins per organization (alongside the singular
owner). - Enforce that an organization always has at least one user with admin capability — i.e.
role ∈ {owner, admin}. The singularowneralways satisfies this. - Forbid removing the
ownerfrom the organization; ownership transfer remains a support/backoffice task in this iteration. - Provide back-end and front-end utilities to answer “does this user have permission X in this org?” cleanly, plus a small
is_owner(user, org)predicate for the few owner-keyed invariants (member-removal exemption, role-validity) that are enforced outside the permission system. - Lay foundations for additional roles (Finance, Support, Developer, …) without re-shaping the architecture.
Non-Goals
- Invitation/accept-link flow. Members continue to be added immediately on invite; an asynchronous invitation flow is a follow-up.
- Audit trail of role changes. Will be addressed via system events as a follow-up.
- Role-as-scope-filter at token issuance. Considered and explicitly deferred — see Roles, scopes, and permissions.
- Self-serve ownership transfer. Changing the
ownerof an organization remains a support/backoffice task in this iteration; a self-serve flow (current owner reassigns to a KYC-verified admin) is a follow-up. - Modifying
PayoutAccount.admin_idsemantics. It is the legal owner of a Stripe Connect account, gates Stripe-account-level actions (onboarding link, dashboard link, delete), and is unrelated to this change. See Relationship toPayoutAccount.admin_id.
Key Concepts
OrganizationRole— new enum (owner,admin,member) attached to a membership.adminandmemberare freely assignable;owneris constrained to exactly one user per organization. During rollout that user is identified byAccount.admin_id; in the end state, by a partial unique index on the role table itself.owner— the singular role per organization. Carries everyadmincapability plus a small set of ownership-tied responsibilities (legal/billing primary contact, identity-verification notification recipient, sole authority for ownership transfer, exemption from member removal). The user holding it must be identity-verified (Stripe KYC); the gate sits on role transitions intoowneronceAccount.admin_idis gone.- Membership — the link between a user and an organization. Today it carries no role; this design adds one.
Account— Polar’s internal financial container for an organization. Holds the credit balance, fee structure, billing identity (name, address) for invoices and receipts, and a singleadmin_id. One per org. Distinct from the Stripe-side payout account.Account.admin_id— today, the singular authorization signal for org-level admin actions: member management, organization deletion, payout-account selection, Finance and payout reads/writes. This design moves all those gates onto role-permission checks and, in the final phase of the rollout, drops the column. While the column still exists (phases 1–5), it is dual-written with theownerrole so the two cannot drift; once the cleanup phase lands, the role is the only source of truth and the responsibilities the column carried (legal/billing primary contact, identity-verification notification recipient, owner-side of the Polar-for-Polar relationship, Stripe-identity-verified KYC gate) are anchored on the user holdingownerinstead.PayoutAccount— represents a Stripe Connect account. User-personal: eachPayoutAccounthas a singleadmin_idset at creation, immutable thereafter, representing the human whose KYC backs the Stripe account. APayoutAccountcan be linked to multiple organizations.PayoutAccount.admin_id— gates Stripe-account-level actions (onboarding link, dashboard link, delete) and the user-personalGET /v1/payout-accounts/listing. Unrelated to organization admin role; not changing in this design.- Policies — the runtime authority for authorization decisions, organized per resource area.
- Scopes — describe what a token can do, not what a user can do. Sessions, personal access tokens, and organization access tokens all carry a scope set; the scope set is org-agnostic. A
scope → implied_permissionsmapping translates each scope into the fine-grained permissions it covers. - Permission — fine-grained authorization unit checked at policy time. Policies require a permission; a request is allowed iff the required permission is in both
implied_permissions(token.scopes)andpermissions(role_in_org). See Roles, scopes, and permissions.
Architecture
Two-layer authorization (recap)
Polar’s authorization model has two distinct layers:- Authentication resolves the caller into an
AuthSubjectand attaches the token’s scope set. - Authorization atomically resolves the target resource, verifies org membership, and evaluates a policy function before the endpoint body runs.
Account.admin_id. After this change, the same policies consult role permissions instead — see Roles, scopes, and permissions. The shape of policies and guards does not change — only the predicate inside them.
Roles, scopes, and permissions
Authorization at policy time consults two independent vocabularies linked by a one-way mapping:- Token scopes — what this token is allowed to do. Org-agnostic, fixed at token issuance, the existing public capability surface (PATs, OATs, OAuth, sessions). Unchanged by this design.
- Role permissions — what this user’s role in this org is allowed to do. Per-org, evaluated at runtime, internal to the RBAC layer. New in this design.
scope → implied_permissions— a mapping in code that says: holding token scopeXimplies the fine-grained permission set thatXcovers. Bridges the two vocabularies.
X, define a same-named permission X and have the map send X → {X}. Day 1, the new layer is functionally equivalent to “use scopes directly.” The first time a role-only refinement is needed, we add the fine-grained permission, list it on the role, and add it to the implied set of whichever existing scope should cover it. Existing tokens continue to imply the (now larger) permission set; they don’t lose capability and don’t need re-issuance.
Why two vocabularies, not one. Tokens are not org-qualified: a single user can be admin in one org and member in another, and one token’s scope set cannot represent both states. Expressing role-as-scope at token issuance therefore either coarsens scopes to the union of roles across orgs (and policies must still gate per-org, making the scope check redundant) or churns tokens on every role change. Keeping scopes and permissions separate, and AND-ing them at policy time, is strictly stricter than either alone, with no per-request token rewriting. It also lets role permissions evolve on a different clock from token scopes — splitting a coarse permission for role purposes is a code change with no impact on existing tokens.
A defense-in-depth filter at PAT/OAT issuance (the issuer cannot grant scopes whose implied permissions exceed their role’s) remains reasonable future work, but does not change where authorization decisions are made.
Initial permission set and scope mapping
Most of the existing scope vocabulary is already granular enough to use as-is. The exception isorganizations:write, which today bundles edit-settings, delete, and payout-account selection. For role purposes we split the bundle into fine-grained permissions while keeping the coarse scope intact:
| Scope (token, unchanged) | Implied permissions (new) |
|---|---|
organizations:write | organizations:edit_settings, organizations:delete, organizations:manage_payout_account |
members:write | members:invite, members:remove, members:set_role |
members:read | members:read (identity) |
transactions:read / transactions:write | identity |
payouts:read / payouts:write | identity |
| every other scope | identity |
| Role | Permissions |
|---|---|
member | Read and write across operational resources (products, customers, subscriptions, benefits, discounts, checkout links/sessions, orders, refunds, files, events, meters, custom fields, license keys, webhooks, metrics, OATs, notifications, customer seats) and read of the member list. Excludes Finance (transactions:*, payouts:*, wallets:*, disputes:read), member management (members:invite, members:remove, members:set_role), and org management (organizations:edit_settings, organizations:delete, organizations:manage_payout_account). Billing-info edits on the org’s Account (legal name, address, etc.) fall under organizations:edit_settings and are therefore admin-only by way of the org-management exclusion. |
admin | Every member permission plus the excluded set above. |
owner | Identical to admin in this iteration. Distinguished from admin by invariants (member-removal exemption, singularity per org), not by additional permissions. The organizations:transfer_ownership permission, if/when self-serve transfer ships, will be owner-only. |
organizations:write), one near-identity scope-to-permission map elsewhere, and three role permission sets. New permissions are added at the seam where role granularity needs to diverge from scope granularity, not preemptively.
Where the permission check lives
A small helper inside the authorization policy layer already centralises today’sAccount.admin_id check. Three policies route through it; each gets a fine-grained permission post-cutover:
- Member management — invite/remove org members. Gated by
members:invite,members:remove,members:set_role(split from themembers:writetoken scope). - Organization deletion — delete the org. Gated by
organizations:delete(split fromorganizations:write). - Payout-account selection — choose or change which
PayoutAccountthis org is paid out to (i.e. setOrganization.payout_account_id). Gated byorganizations:manage_payout_account(split fromorganizations:write). This is not the same as managing a Stripe Connect account; that remains gated byPayoutAccount.admin_id.
Account.admin_id check directly without going through the helper, and switch to the corresponding permission check too (transactions:*, payouts:*, wallets:*, disputes:read — admin-only on the role-permission side). Billing-info edits on the org’s Account (legal name, address, etc.) are not a separate Finance gate; they piggyback on organizations:edit_settings, which is admin-only by role-permission assignment.
The payout-account policies (operations on a specific PayoutAccount itself: onboarding link, dashboard link, delete) check PayoutAccount.admin_id and do not move. A PayoutAccount can be linked to multiple organizations (a real feature), so the legal Stripe-side owner is not a per-org concept; routing those checks through org-role would let any admin of any consuming org reach into a Stripe identity that isn’t theirs.
A small set of owner-exclusive invariants live alongside policies but aren’t permissions: the member-removal exemption (the owner cannot be removed from the org) and the singularity rule (exactly one user carries owner per org). They are data-integrity rules enforced in the membership service alongside the admin-capability invariant. While Account.admin_id still exists, the singularity rule is also expressed by anchoring owner to that column via dual-write; once the column is dropped, it is expressed by a partial unique index on UserOrganization (organization_id) WHERE role = 'owner'.
No new dependency aliases are needed; existing scope-gated guards keep their names and required scopes. The role lookup remains a single helper: given a user and an organization, return their role (or None if not a member). The permission check is one step on top: required ∈ implied_permissions(scopes) ∩ permissions(role). Both are consumed by policies and by service-layer invariants.
Relationship to Account.admin_id
Account.admin_id is being decommissioned by this work, but only after the role system has been validated in production. While the column exists (phases 1–5), it is treated as the legacy storage for “who is the org’s owner” and dual-written with the role: setting Account.admin_id (today via org creation and the backoffice change_admin flow) demotes the previous owner to admin and promotes the new admin to owner in the same transaction; conversely, the role-change endpoint refuses any transition that would put a user other than Account.admin_id into the owner role or move that user out of it. The two states cannot drift.
The cleanup phase (phase 6) drops the column and re-homes the responsibilities it was tracking:
- Singularity invariant — moves from “anchored on the singular
Account.admin_idcolumn” to a partial unique index onUserOrganization (organization_id) WHERE role = 'owner'. - KYC / identity-verification gate — today enforced inside
account_service.change_adminwhenAccount.admin_idis mutated; moves onto role transitions intoowner(which only the backoffice flow can perform until self-serve transfer ships). - Legacy joins —
repository.get_admin_userand any caller that fetched “the org’s admin” viaAccount.admin_idswitch to a query againstUserOrganizationfiltered torole = 'owner'. - Notification routing and the Polar-for-Polar owner-side relationship — re-anchored on the user holding
owner. The user identified by these flows is unchanged; only the lookup path changes.
Relationship to PayoutAccount.admin_id
PayoutAccount.admin_id is the legal owner of a Stripe Connect account. It is set at the time POST /v1/payout-accounts/ creates the account and is not mutable through any code path afterwards. It continues to gate Stripe-account-level actions (onboarding link, dashboard link, delete) and the user-personal GET /v1/payout-accounts/ listing.
Because a PayoutAccount can be linked to multiple organizations, its admin is necessarily a per-Stripe-account concept rather than a per-org one. This design does not move authorization for those actions. What the new role-permission system does govern in the payout space is the org-level decision of which PayoutAccount to use — i.e. setting Organization.payout_account_id, gated by the organizations:manage_payout_account permission. The conservative rule for that selection is that the caller can only switch to a PayoutAccount they themselves own (PayoutAccount.admin_id == subject.id), preserving Stripe-identity boundaries between orgs.
Data Model
A newOrganizationRole enum with three members: owner, admin, member. The enum is attached to the user–organization membership row.
The new role attribute is required (non-null) and defaults to member for new memberships. Backfill maps each existing Account.admin_id user into the owner role on their membership of the owning org; everyone else is member. The admin role starts unused on existing data — it is opt-in promotion above member.
While Account.admin_id still exists (phases 1–5), service-layer validation enforces that the user with role owner on a membership matches Account.admin_id on the org’s Account, and that the Account.admin_id user always carries owner. The role-change endpoint cannot move a user into or out of owner; ownership transfer flows through Account.admin_id mutations (today: backoffice change_admin, which performs the role swap as a side-effect).
After phase 6, Account.admin_id is gone. The singularity invariant is then enforced by a partial unique index on UserOrganization (organization_id) WHERE role = 'owner', and the KYC gate is enforced on role transitions into owner (still only callable by the backoffice flow, which mutates the role directly instead of Account.admin_id). PayoutAccount.admin_id is unchanged throughout.
Authorization Flow
The diagram changes nothing structurally from today; only the content of the policy step changes (permission intersection check instead ofAccount.admin_id lookup).
API Surface
Endpoints whose authorization already routes through the central authorization helper gain permission-based behaviour automatically when that helper is updated (its body switches from anAccount.admin_id check to the permission intersection check). This covers member listing, invite, removal, organization deletion, and finance/payout endpoints.
A net-new endpoint changes a member’s role: PATCH /v1/organizations/{id}/members/{user_id} with a role body, guarded by the members:set_role permission. Only admin and member are accepted in the body; transitions to or from owner are rejected by this endpoint and flow exclusively through the ownership-transfer path (today: backoffice change_admin, which mutates Account.admin_id and the role together; post-phase-6, the same backoffice flow mutates the role directly with the KYC gate inline). The members listing response gains a role field. The pre-existing is_admin boolean on that response becomes a transitional alias for role ∈ {owner, admin} and is removed once the dashboard has migrated.
Frontend
The dashboard’s auth context gains role information per organization, populated from the existing per-user organizations endpoint augmented to includerole. Two small hooks live on top: one returns the user’s role in a given org, and one answers a permission question (hasPermission(perm, org)). The permission hook reuses the same scope → implied_permissions and role → permissions tables the backend uses, generated from a single source. The backend remains the source of truth on every request.
The Settings → Members UI uses these hooks to render a role-change control on each row (gated by members:set_role), to disable destructive controls when the action would leave the org without anyone holding admin permissions, and to surface the policy denial message verbatim. The owner row renders the role-change control disabled (with a tooltip pointing to support) and its remove-member affordance disabled. Pages currently gated on the implicit “is admin” check (Finance) move to the new permission hook.
Invariants
Exactly one owner per org
Enforced differently before and after phase 6. WhileAccount.admin_id exists, the invariant is anchored on it: service-layer validation requires that the user with role owner matches Account.admin_id, and that the Account.admin_id user always carries owner. The role-change endpoint refuses transitions that would violate either direction; ownership transfer flows through Account.admin_id mutations, which perform the role swap as a side-effect inside the same transaction. After phase 6, the column is gone and the invariant is enforced by a partial unique index on UserOrganization (organization_id) WHERE role = 'owner'; ownership transfer flows directly through the role.
Owner cannot be removed from the org
Enforced in the membership service: theowner is exempt from removal. To change ownership, the operator must first transfer it (today: backoffice change_admin); only then can the previous owner be demoted or removed. This is a data-integrity rule, not a permission.
At least one user with admin capability per org
Enforced in the membership service: any operation that would leave an organization with zero users inrole ∈ {owner, admin} is rejected. The check is evaluated against the post-mutation state inside the same transaction. The owner-non-removable invariant alone guarantees this for any org that has an owner (which every org does, by the singularity invariant); the explicit check is defence-in-depth.
Read-only impersonation rejection
The recently-codified read-only-impersonation invariant (#11303) carries through: impersonation sessions hold no_write scope, and the role-mutation endpoint requires the members:write scope (which is what implies the members:set_role permission), so impersonation cannot mutate roles. No additional safeguards needed.
Rollout
The change ships as a sequence of PRs. Each step preserves two invariants at every boundary:- During app/DB version skew, both old and new code can read and write the schema correctly.
- At any point, all running code agrees which signal authorization decisions follow. Pre-cutover (Phase 3) that signal is
Account.admin_id; from Phase 3 through Phase 5 it is the role-permission check, withAccount.admin_iddual-written to keep the legacy column consistent for any code that still reads it. The dual-write means the two signals never disagree on the only user they both speak about (the owner). Phase 6 retiresAccount.admin_identirely; from that point the role table is the only signal.
Phase 1 — Additive schema, dual-write
Add the role attribute with a default ofmember. Backfill maps each Account.admin_id user to the owner role on their membership; everyone else stays member. The application keeps the role aligned whenever Account.admin_id is set (org creation) or changed (the backoffice change_admin flow): the previous owner is demoted to admin, the new admin is promoted to owner, in the same transaction. The owner-validity validation that guards this dual-write — “only the Account.admin_id user may carry owner, and that user always does” — ships now too, even though no caller-facing role-change endpoint exists yet, so any future or backfill write is checked.
The role-to-permissions and scope-to-implied-permissions tables (and the fine-grained permission strings they reference) also land in this phase, in code, but are not yet consumed by any policy. Shipping them early keeps Phase 3 a pure switch-flip and lets the Phase 3 verification script reference the same tables.
Authorization code does not yet read the new role attribute. Old code that doesn’t know about the new attribute continues to function because the default keeps inserts well-formed.
Phase 2 — Tighten the schema
Once every row has a value and all running code writes one, tighten the attribute to non-null. Schema-only change, no application work. Cross-table consistency between role andAccount.admin_id continues to rest on the service-layer validation shipped in Phase 1 (Postgres CHECK can’t span tables declaratively, and the column is being dropped in Phase 6 anyway, so a trigger isn’t worth the trouble).
Phase 3 — Authorization cutover
Backend authorization switches from theAccount.admin_id check to the permission intersection check (required_perm ∈ implied(scopes) ∩ permissions(role)), reading the role-permissions and scope-implied-permissions tables shipped in Phase 1. The new role-change endpoint, owner-non-removable invariant, and admin-capability invariant ship in this phase. The members API response gains the role field; the legacy is_admin boolean becomes a derived alias for role ∈ {owner, admin}. The PayoutAccount.admin_id policy is unchanged.
A pre-deploy verification asserts that every pre-existing Account.admin_id user has the owner role on that org, that exactly one owner exists per org, and that no organization is left without admin capability. The deploy is gated on that check.
Phase 4 — Frontend adopts role
The dashboard readsrole directly via the new hooks, gates UI controls on the permission helper, and displays policy denial messages from the backend. The permission tables consumed by the hook are generated from the backend source (the same Phase 1 tables) into a TypeScript artefact at build time, so the two cannot drift. The backend continues to return both role and the legacy is_admin field, so any browser tab still on the previous bundle is unaffected.
Phase 5 — Frontend cleanup
Remove the legacyis_admin field from the API. The Account.admin_id ↔ owner role dual-write stays in place through this phase — it is retired in Phase 6, alongside the column itself.
The dashboard auto-deploys on every merge but does not currently force-refresh stale tabs, so a tab from phase 3 keeps consuming is_admin until the user reloads. Three acceptable orderings for phase 5:
- Time-based — wait long enough after phase 4 that weekly-active sessions have refreshed at least once. Cheapest path; small residual risk for very long-lived idle tabs.
- Bundle-mismatch prompt — ship a “new version available — please refresh” prompt as part of phase 4. Phase 5 can then ship immediately because any stale tab self-heals.
- Accept the break — ship phase 5 immediately after phase 4 and accept that any dashboard tab still on the previous bundle will see errors until the user reloads. Cheapest in engineering effort; the cost is a brief degraded experience for users with long-lived tabs.
Phase 6 — Drop Account.admin_id
After phase 5 has soaked in production long enough to trust the role system as the live authorization signal, retire Account.admin_id. The work splits into three steps that ship together (the read-side migration first, the schema change last) so the column is never read after the dual-write stops:
- Re-home the reads. Migrate every remaining call site that still references
Account.admin_id—repository.get_admin_user, the backofficechange_adminendpoint’s storage layer, any notification or Polar-for-Polar lookup that resolved “the org’s admin user” via the column, and any helpers that closed over it — to queryUserOrganizationfiltered torole = 'owner'. The four authorization policies that historically readAccount.admin_idare already off it (they switched to the permission check in Phase 3) so don’t need further migration here. The backoffice ownership-transfer flow becomes a role mutation: it runs the existing KYC/identity-verification check on the incoming admin and then promotes them toowner(demoting the previous owner toadmin) directly, instead of mutatingAccount.admin_idand letting the dual-write echo into the role. - Add the role-side enforcement. Create a partial unique index on
UserOrganization (organization_id) WHERE role = 'owner'so the singularity invariant survives the column’s removal. Move theIdentityVerificationStatus.verifiedgate, today insideaccount_service.change_admin, to live alongside role transitions intoowner. - Drop the column. Remove
Account.admin_idfrom the schema; remove the dual-write from any code path that previously kept it aligned with the role.
Account.admin_id (a grep gate plus a database log audit covering the previous bake period) before the destructive migration runs. After this phase, the role table is the only source of truth for ownership; Account is a pure financial container.
Out of Scope (Follow-Ups)
Invitation / accept-link flow
Today, inviting a user adds them to the organization immediately. A proper invitation flow (email accept-link, expiring tokens) is a separate, additive feature that does not depend on this design.Audit trail via system events
Role changes, member adds, and member removes should emit system events for support and admin-side audit. The system events infrastructure recently landed and is the natural transport. Layering this on requires no schema changes from this design.Role-as-scope-filter at token issuance
A defense-in-depth design where personal-access tokens and organization-access tokens can only be issued with scopes whose implied permissions are a subset of the issuer’s role permissions. Attractive but introduces token-staleness questions on role change and a second source of truth that must stay coherent with the role-permission table. Deferred until more roles ship and the trade-off is sharper.Open Questions
- Phase 5 strategy. Time-based wait, bundle-mismatch prompt, or accept the brief break for stale tabs?
- Phase 6 bake time. How long does the role system run as the live authorization signal (with
Account.admin_idstill dual-written) before we trust it enough to drop the column? Concrete signals to wait on (zero policy-denial reports tied to role lookups, audit log showing no remainingAccount.admin_idreads in production for N days) help anchor the decision. - First-class “Finance” role. RBAC.md anticipates Finance/Support/Developer roles. Should the enum reserve those values now (forward-compat) or add them when needed?
- Permission-table source of truth. The
role → permissionsandscope → implied_permissionstables live in code in this design, generated into a frontend artefact for the permission hook. Is that sufficient long-term, or do we need a richer config layer (YAML, DB) once customer-facing role customisation is on the roadmap?

