Skip to main content

Authentication & authorization

Every /v1/* route runs the requireTenant middleware (api/src/middleware/auth.ts) before any handler. It establishes who the caller is and which tenant they act for; the tenant is never taken from a request header or body.

Credentials accepted

requireTenant inspects the request and branches between two verification paths on the IdentityProvider port:

  • API key — a Authorization: Bearer voc_sk_… token, or an X-API-Key header. Routed to identity.verifyApiKey(key).
  • Session — anything else: a Clerk session JWT presented as Authorization: Bearer <jwt>. Routed to identity.verify(c.req.raw).
const authz = c.req.header('authorization');
const bearer = authz?.startsWith('Bearer ') ? authz.slice(7).trim() : undefined;
const apiKey = bearer?.startsWith('voc_sk_') ? bearer : c.req.header('x-api-key');

const result = apiKey && identity.verifyApiKey
? await identity.verifyApiKey(apiKey)
: await identity.verify(c.req.raw);

On failure the middleware returns 401 { error: { code: 'unauthenticated', … } }. On success it reads clientAccountId, userId, email, roles, and permissions off the principal and pins them onto c.var.tenant. If the principal authenticated but has no tenant membership (e.g. a brand-new user who hasn't created a workspace), it returns 403 forbidden so the SPA can prompt workspace creation.

Sessions via Clerk

Sign-in is handled entirely by Clerk from the SPA (email code, Google, SSO — configured in the Clerk dashboard, not in this codebase); there is no first-party /auth route. The API only ever verifies the resulting credential — identity does not live in this database, which is what keeps the application's Postgres portable. The makeClerkIdentity adapter (packages/adapters/src/clerk-auth.ts) verifies the Clerk session JWT with the official @clerk/backend SDK:

  • verifyToken(jwt, …) validates the token against Clerk's JWKS (fetched and cached by the SDK; fully networkless when a CLERK_JWT_KEY PEM is supplied), enforcing signature and expiry. Optional CLERK_AUTHORIZED_PARTIES constrains the accepted azp (SPA) origins.
  • The active organisation, role and user come from the verified claims: org_id / org_role / org_slug (the adapter also accepts the v2 compact o claim), and sub (the Clerk user id).

A Clerk Organization (org_id) maps 1:1 to a tenant.client_account via auth_org_id (now a text column holding the org_… id); on first sighting the tenant is provisioned by an idempotent upsert. RLS still keys on the numeric client_account_id, so nothing downstream changes. A principal authenticated without an active organisation (e.g. a brand-new user who hasn't created or selected a workspace) is returned with no tenant, so requireTenant returns 403 and the SPA prompts workspace creation.

Permissions and can()

A principal's effective permissions are the union of their standard role (derived from the Clerk org_roleorg:admin / org:member, with the org: prefix stripped — plus the global-admin flag) and any assigned custom roles (identity.member_role, keyed on the Clerk user id). The catalogue and standard roles live in packages/core/src/permissions.ts.

Tenant mutations are gated in the route with the helpers in api/src/middleware/permissions.ts:

if (!can(c.var.tenant, 'integrations.manage')) return c.json(forbidden('integrations.manage'), 403);
  • can(ctx, perm) is a membership test against ctx.permissions.
  • forbidden(perm) returns the standard { error: { code: 'forbidden', message: 'Missing permission: …' } } body, paired with HTTP 403.
  • isSystemAdmin(ctx) is true only for the platform (cross-tenant) admin — surfaced as the operator role — and gates system-wide surfaces such as feature flags.

The permission catalogue (resource.action keys):

CategoryKeys
Insightdashboard.view, reports.view
Responsesresponses.view, responses.edit, responses.delete, responses.export
Verbatimverbatim.view, verbatim.code
Surveyssurveys.view, surveys.edit, surveys.distribute
Alerts / Leadsalerts.view, alerts.manage, leads.view, leads.manage
Engagementbadges.manage
Adminagents.manage, users.manage, roles.manage, account.manage, compliance.manage, gdpr.manage, integrations.manage, feedback.manage

Standard roles: system_admin (*, global), client_admin (*), manager, analyst, agent. API keys carry their permissions via scopes — see API keys.

IP allowlist

After requireTenant, the ipAllowlist middleware (api/src/middleware/ip-allowlist.ts) enforces an optional per-tenant allowlist read from account settings. An empty list (the default) is a no-op. When set, the client IP (from cf-connecting-ip, falling back to the first x-forwarded-for hop) must match an entry by exact value, a trailing-dot prefix (203.0.113.), or *; otherwise 403 forbidden. Full CIDR matching is a noted refinement.