Identity & Access Management
The/iam/* endpoints let you manage users, roles, and partner staff
across the Tagada platform from your own application — typically a
sales CRM, a partner portal, or any backoffice that needs to
provision or audit access without going through the dashboard UI.
The same role matrix that gates the dashboard gates these endpoints.
There is no “REST is more permissive” path.
Why a separate auth model?
Bearer API keys act on behalf of the account owner with full authority. That’s fine forcreateStore or refundPayment, but for
mutations like “grant superadmin” it would mean a leaked key becomes
a one-shot privilege-escalation vector with no human in the loop.
To avoid that, all IAM mutations require a real Clerk session so
that:
- The audit log always names a Clerk user (not “the API key for account X”).
- Role escalation is bounded by what that user is allowed to assign — even if their session is stolen, they can only grant what they themselves can grant.
- API keys cannot be silently used to flip roles by anyone with read access to a key store.
Concepts
tgdRoles
The canonical list of Tagada platform roles. Stored on Clerk as
publicMetadata.tgdRoles (single source of truth) and mirrored
locally on public.users.tgd_roles for indexed queries.
whitelabelOwnership
The partner slug a user is scoped to, or null for unscoped (Tagada
internal) staff. A partneradmin with whitelabelOwnership: "acme"
can only manage staff for the acme partner.
Partner
A partner is a Tagada-platform-wide concept (CRM + processor), not exclusive to TagadaPay. A partner can have the CRM only, the processor only, or both. Partner staff is tracked in thememberships table (one row per (user, partner) pair, with a
status of pending, active, or revoked).
Partner Ops (Payfac white-label)
Payfac white-label partners (e.g. Suby) can operate their own TPA book in the same Ops dashboard Tagada uses — without receiving platform-privileged roles (superadmin / founder). Same job string:
payment_ops — scope separates the books.
Do not grant deprecated roles (
tagadapaymentAdmin, partner_ops). Use payment_ops + scope.
See the internal ADR docs/iam-model.md for anti-patterns.
Data scope: only TPAs where
tagadapay_accounts.partner_id = <partner.id>. Cross-book access
returns 403. Tagada-only surfaces (Risk, global deals, eligibility
rules, partner CRUD) stay behind payment_ops / platform auth.
Roles at a glance
There are ninetgdRoles in the catalogue (including deprecated
aliases), split across two scopes (Tagada-internal vs partner-scoped).
The diagram below shows where each role lives; the two tables below
say what each role can do day-to-day.
The role tree
Plain-text version (in case mermaid doesn’t render in your viewer):Note —accountmanagerappears on both branches. The same role string can be either unscoped (Tagada-wide AM seeing every partner) or scoped (AM dedicated to one partner) depending on the user’swhitelabelOwnershipvalue.
One-line role guide
Common scenarios
The single source of truth a non-engineer usually needs:Self-promotion is always denied. No role — not evensuperadmin— can edit its owntgdRoles. You always need a peer to grant or revoke a role on your own account.
Authorization matrix
Every mutation goes through one of two pure policy functions.canAssignTgdRole
Used by every endpoint that writes to tgdRoles.
A user can never escalate themselves — even a superadmin cannot
grant themselves a role they don’t already hold via this endpoint.
Assignable role sets
The exact sets the backend will accept on a write — useful when building a role picker that must not show options the server will reject. They are scope-disjoint by design: an internal-only role likepayment_ops cannot be granted to a partner-scoped user, and a
partner-only role like partneradmin only makes sense with a
non-null whitelabelOwnership.
The internal set deliberately excludes
partneradmin (requires a
scope — route through “set partner scope + set roles” instead) and
payment_ops (legacy role, no longer granted at the REST surface).
The partner set deliberately excludes the internal-only roles
because a partneradmin cannot mint Tagada staff.
canManagePartnerStaff
Used by revoke / delete / resend-invitation.
canManagePartners
Used by the partner-entity verbs (list / get / create / update / archive).
canManageOrgMembership
Used by the merchant-org membership verbs
(list / add / remove / change_role) and by the merchant
service-API-key verb (manage_api_keys) — the new IAM surface
that replaces the Clerk dashboard for managing who is a member of
a merchant Clerk org, and that lets a CRM reseller provision the
merchant’s public-API tokens.
Self-
remove and self-change_role are always denied (locking yourself
out is rarely intentional). Self-add is allowed (support join flow).
The membership verbs live on the CRM tRPC surface
(api.iamOrgs.{list,membersList,membersAdd,membersRemove,membersSetRole}),
not the public REST /iam/* router — they’re only ever called from
the IAM panel in the CRM. Promote to REST when an external caller
needs them.
The manage_api_keys verb has no self-action guard (keys are
not scoped to a user, so there is nobody to lock out) — it flows
straight through the same actor × org-partner gate as the other
verbs. It is exposed on both surfaces: the CRM tRPC
(api.partners.{listMerchantApiKeys,createMerchantApiKey,revokeMerchantApiKey})
and the public REST /iam/* router (see
Merchant service API keys below), so a
white-label partner can manage keys either from the CRM UI or
server-to-server.
Why create and archive are stricter:
create= onboarding a new partner. In practice this happens after out-of-band steps (legal contract, commercial terms, banking setup), so it stays a Tagada-team-only verb to prevent stray rows.archive= the only delete-shaped verb a partner exposes. It has billing implications (open settlement runs, pending payouts), so we gate it behind the most-privileged role.
update callers that pass status="offboarded" are rerouted internally
through archive, so the strict policy applies regardless of which
verb the adapter used.
partneradmin is allowed list (UI affordance — auto-clamped to
their own partner) but excluded from the write verbs — partner
self-service is the partner-staff lifecycle, not partner-entity CRUD.
accountmanager is allowed list for the same reason: the
merchant-org table needs partner attribution.
Authentication
The/iam/* endpoints are mounted under /api/v1/iam/* (the
internal OpenAPI router) and accept Clerk session
authentication only. Bearer API keys, builder sessions, and any
other service-account credential are rejected with 401.
From a browser-based dashboard (same Clerk instance)
The session cookie set by Clerk’s standard sign-in flow is enough. Justfetch() the endpoint with credentials: 'include'.
From an external app (the sales CRM use case)
Use Clerk’s standard sign-in flow (OIDC / Clerk-hosted UI / Clerk SDK) on your own domain to sign the operator in against the same Clerk instance that powers Tagada. Then either:- Forward the Clerk session cookie when proxying through your backend, or
- Mint a short-lived Clerk session JWT with
getToken()and pass it asAuthorization: Bearer <jwt>from server to server.
Why these endpoints aren’t in the right-hand API Reference
The auto-generated reference panes on the right side of every page are built fromopenapi.json, which is the public-API spec
(API-key authenticated). IAM is intentionally absent from that file.
The endpoint catalogue below is maintained by hand and lives only in
this guide.
Endpoint catalogue
All paths are relative to the base URL of the API deployment you are calling —https://api.tagada.io in production (the value of
NEXT_PUBLIC_APP_URL). Note this is not app.tagada.io, which
serves the CRM single-page app and will return a 405 / HTML page
for these routes.
Permission probes
These never return403. They always return 200 with a decision
object — use them to render-then-mutate (see Patterns
below).
Partner entities (CRUD)
Lifecycle of partner records themselves (rows inpartners).
Mounted under a distinct partners-admin prefix so a slug literally
named list / create / archive cannot route ambiguously.
Partner-staff lifecycle
Scoped to one partner via the:partnerSlug path parameter. Caller
must satisfy canManagePartnerStaff for that partner.
Internal users (Tagada staff)
Superadmin-only. These act on unscoped users (whitelabelOwnership IS NULL) — Tagada operations team, account
managers without a partner scope, etc.
Organizations
Merchant service API keys
Lets a CRM reseller (e.g. BNP) mint / list / revoke anorg:admin
service API key for each of its merchant accounts. The minted
key is the Bearer token the merchant then uses against the
public API (/api/public/v1/*) — these endpoints are the IAM way
to provision that token without going through the merchant
dashboard.
Authorization is canManageOrgMembership('manage_api_keys'): a
partneradmin scoped to X can only touch merchants whose
accounts.partner_id resolves to X (scope clamp), and the account
ownership is re-checked on every call. payment_ops / superadmin
reach any merchant. Keys are tagged with a partner-provisioning
marker, so only keys this partner minted are listed or
revocable — the merchant’s own self-served keys are never exposed
here.
Partner attribution (accounts)
Re-attribute a merchant account (or an orphan partner-managed account) to a different partner. Every call is recorded in thepartner_change_log audit table and fans out across all of the
surfaces that have to stay in sync — there is no longer any way to
update one of them without the others.
accounts/change-partner payload
accounts.partner+accounts.partner_id(Postgres) — legacy slug column and canonical FK, written atomically.hub_requests.business_info.partner(Postgres JSONB) — keeps every in-flight onboarding request consistent with the org’s new partner.org.publicMetadata.whitelabel(Clerk) — drives theactiveOrgWhitelabelsession claim and per-partner branding. Also auto-adds everypartneradminof the new partner to the org.- Clerk member users’
publicMetadata.partnertag — so existing members see the new attribution on their next token rotation. - Stripe Connect sub-accounts’
payments_pricinggroup — resolved frompartners.stripe_pricing_groupso commissions follow the partner’s grid immediately. - ClickHouse
stripe_txns_all.partner— historical backfill so commission reports for past traffic are restated under the new partner. hub_request_assignmentstags — keeps the routing/queue view aligned with the new attribution.
partner_change_log.surfaces JSONB column) so an
operator dialog can show “Stripe OK, ClickHouse deferred, …” without
having to query the audit table.
orgs/assign-to-partner (deprecated)
Kept for the older CRM build that hasn’t migrated to the new payload
yet. Internally it just wraps change-partner with a synthetic
reason="auto:assignOrgToPartnerAction(legacy REST alias, …)" so the
audit trail stays honest. There is no behavioural difference at the
surface level — both endpoints write the same audit row and trigger
the same 7-surface fan-out.
Read-side queries
Both query the local Clerk mirror (synced via webhook). Eventually consistent — typically <1s lag. For freshness-critical paths (login, immediately after a role re-grant) call the Clerk Backend API directly.Patterns
”Render-then-mutate” — pre-flight permission checks
Before showing a destructive button, hitPOST /api/v1/iam/permissions/assign-role or
POST /api/v1/iam/permissions/manage-partner-staff. They always
return 200 with { ok: boolean, reason? } so you can disable the
button instead of letting the user mash it and discover the 403.
Inviting a teammate
POST /api/v1/iam/partners/:partnerSlug/staff/invite is idempotent
on (partnerSlug, email):
- If the email is already a Clerk user scoped to this partner,
the endpoint just appends the requested roles to their existing
tgdRolesand returns{ status: "role_updated" }. - Otherwise it creates a fresh Clerk invitation with the partner’s
branded email + redirect URL and returns
{ status: "invited" }.
roles[] payload is restricted to PARTNER_ASSIGNABLE_ROLES —
see Assignable role sets. The partner-staff
set-roles endpoint enforces the same constraint. To grant an
internal-only role like payment_ops you must first detach the user
from the partner via internal-users/set-partner-scope and then
call internal-users/set-roles (both superadmin-only).
Cross-partner / hijack guard: if the email belongs to a Clerk user
who is already scoped to a different partner, or is an internal
Tagada staff member, or owns a merchant org, the invite is
rejected even by superadmin callers — superadmin must
intervene via internal-users/set-partner-scope first.
Revoke vs delete
- Revoke (
POST .../revoke) is the soft path: strips Clerk roles and scope, flips the audit row inmembershipstorevoked. The Clerk user account itself is untouched. - Delete (
POST .../delete) is the hard path: drops the audit row entirely (and revokes any pending invitation in Clerk). Use this when the user was added by mistake and shouldn’t appear in the audit log at all.
Creating a partner
POST /api/v1/iam/partners-admin/create is superadmin-only. Pass
adminEmails: ["alice@acme.com", ...] to invite the founding
partner admins as partneradmin in the same call. Each invitation
is idempotent on (partnerSlug, email). Failures on individual
invitations don’t roll back the partner row — they show up as
{ status: "error", error: "..." } entries in the response’s
invited[] and you can re-invite from the Team tab.
Archiving (soft delete) a partner
There is no hard delete on partner rows. Archiving setsstatus="offboarded" so:
- Merchants still resolve their
partner_idcorrectly. - Billing snapshots and settlement reports keep referencing the row.
- The slug remains reserved (you cannot create a new partner with the same slug after archive).
previousStatus so
you can detect “already archived” at the UI layer:
update with status="offboarded" is rerouted internally
through archive, so the strict (superadmin-only) policy applies
either way.
Provisioning a merchant’s API key (end-to-end)
The merchant key lifecycle spans two auth models: you mint the key through IAM (Clerk-session), then the merchant uses it as a Bearer API key against the public API. Concretely:- Mint (Clerk-session, partneradmin of
bnp). The secret comes back exactly once — store it immediately.
- Use (Bearer API key — the merchant’s own integration). The
tokenfrom step 1 is now a normal public-API credential:
- List what you’ve provisioned for that merchant (secrets are never returned on read):
- Revoke when rotating or offboarding:
api.partners.* tRPC procedures, which delegate to the identical
IAM use-cases (same scope clamp, same ownership check, same
reveal-once behaviour).
Read-side queries
Theusers.list and users.find endpoints query the local mirror
synced from Clerk via webhook. The mirror is eventually
consistent — typically <1s lag. For freshness-critical paths
(login, immediately after a role re-grant) call the Clerk Backend
API directly.
The partnerScope filter on users.list accepts the sentinel
"__internal__" to select unscoped staff (whitelabelOwnership IS NULL).
Errors
All/iam/* endpoints return standard HTTP codes:
The body is a JSON object with
code (a string from the ZSA error
enum) and message (a human-readable description).