Security & access
The access model
How polimorf decides who can reach what: roles, row-level rules, tenant isolation, keys, and revocation. This is the model you build against — the marketing posture lives on the /security page.
The layers
Access is decided in stages, each one narrowing the last. A request must clear all of them before it touches a single record:
- Identity — a valid bearer token (a user session or an API key) names the caller. No token, no entry.
- Tenant — the request resolves to exactly one organization, and the database session is pinned to that organization's schema.
- Role — coarse gates decide whether the caller may perform a kind of action at all (read, write, admin).
- Row & field — fine-grained allow-rules decide which records and which fields the caller actually sees.
The default at the fine-grained layer is deny: a role-bearing user with no matching allow-rule sees nothing, never everything.
Accounts & invite-only access
There is no public sign-up into an organization. Membership is created only by an existing admin sending a role-scoped invitation; the recipient redeems a single-use token. The invitation token is never stored in plaintext — only a keyed hash is kept, so a database dump cannot redeem a live invite, and an expired-but-pending invite will not leak the invitee's details.
Every membership carries an account_type that decides which surface the person lands in. There are two, and the database constrains the column to exactly these values:
| account_type | Surface | Reach |
|---|---|---|
| developer | Build console | Schema, workflows, settings — gated by role on top |
| client | Client workspace | One organization, scoped to the granted role |
Account type and role are independent gates. Build-console routes additionally require a developer account type, so a client membership cannot reach schema or settings endpoints even if its role would otherwise allow the action.
Roles & RBAC
Each organization is seeded with three system roles. They are flat — there is no inheritance — and a role is a named bag of (resource_type, action) grants. System roles cannot be deleted; you can create your own roles alongside them and attach your own grants.
| Role | Grants |
|---|---|
| admin | Full access: all resources, plus organization transfer and invitations |
| editor | Author content and media, manage pages, run workflows; read-only on keys, settings, roles |
| viewer | Read-only across content, media, pages, sites, workflows, schema, roles |
At the route level, mutations require a role in the right band — config changes require admin, content writes require at least editor — and a platform admin passes every role gate. Roles travel inside the access token, so a request never has to re-fetch them; the rule set those role slugs map to is cached briefly and invalidated the moment a role or permission changes.
Roles vs. row rules
A role decides whether you may read content at all. A row rule (below) decides which content rows you read. You almost always want both.Two-factor (TOTP)
Accounts can enrol time-based one-time-password two-factor, compatible with any standard authenticator app. Enrolment is a two-step handshake: request a setup secret and provisioning URI, then confirm a generated code before it is switched on — so a mistyped secret can never lock you out.
On confirmation you are issued a set of single-use backup codes, shown once; only their hashes are stored. Once two-factor is on, login becomes a two-call flow — password first, then the TOTP code — and the intermediate challenge token is a distinct, short-lived token that cannot be used as a session:
# 1 · Exchange credentials. If the account has two-factor on,
# the response carries an MFA challenge instead of a session.
curl -X POST https://api.polimorf.com/admin/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "you@agency.com", "password": "…"}'
# → { "mfa_required": true, "mfa_challenge_token": "…" }
# 2 · Verify the TOTP code to mint the real access + refresh tokens.
curl -X POST https://api.polimorf.com/admin/v1/auth/mfa/verify \
-H 'Content-Type: application/json' \
-d '{"challenge_token": "…", "code": "123456"}'
# → { "access_token": "…", "refresh_token": "…", "mfa_required": false }Row-level access (ABAC allow-rules)
Beyond roles, you attach attribute-based allow-rules to the content_entry resource. Each rule's condition is compiled into a SQL row predicate and pushed down into the query's WHERE clause — so listing, counting, and paging all enforce the same rule. There is no fetch-then-filter gap a clever query could slip through.
Conditions reference fields with the resource. prefix and substitute the caller's own attributes (for example $user.id) at evaluation time. The operators are the intersection of what the rule engine and the query compiler both understand:
| Operator | Meaning |
|---|---|
| eq / ne | Equals / does not equal |
| in / not_in | Member / not a member of a list |
| gt / gte / lt / lte | Greater / less than (or equal) |
| contains | Field contains the value |
# A permission rule on the content_entry resource. The condition is
# compiled into a SQL WHERE predicate — it scopes every list and read,
# not just the one record you fetch.
POST /admin/v1/permissions
{
"role_id": "…",
"resource_type": "content_entry",
"action": "read",
"conditions": {
"and": [
{ "field": "resource.region", "operator": "eq", "value": "$user.region" },
{ "field": "resource.status", "operator": "in", "value": ["open", "active"] }
]
}
} Several allow-rules combine as a union (a row is visible if any rule admits it); leaves within one rule's and group must all hold. The model is deliberately fail-closed at every edge:
- A role-bearing user with no matching allow-rule resolves to deny-all — empty result, not unrestricted.
- A condition that cannot be translated to SQL is rejected when you save the rule, not silently ignored at query time; if one ever slipped through, the runtime drops it rather than failing open.
- A reference like
$user.regionthat resolves to null at evaluation time drops its rule rather than matching everything.
Where row rules apply
Row and field filtering attaches to role-bearing user sessions in the dashboard. Anonymous public reads and API-key callers are not row-filtered today — they stay on the coarser scope/role gate. Keep anything sensitive behind authenticated, role-bearing access.Tenant isolation
Every organization's content lives in its own dedicated Postgres schema, on a routable cluster and database. When a request resolves to an organization, the database session is pinned to that schema with SET LOCAL search_path for the life of the transaction — so a query physically cannot reach another organization's tables, regardless of what the application code does. Isolation is a property of the session, not a filter the code has to remember to add.
Shared identity — users, memberships, and API keys — lives in a separate control plane; the per-organization content data does not. Only organizations with an active status are admitted, and a read-only routing flag can force a session read-only. A client never gains a view into any other organization, and a developer account switches between organizations explicitly, one token at a time.
API keys
For machine access, mint scoped API keys. The raw secret is returned once at creation; only a peppered HMAC-SHA256 hash and a short non-secret prefix are stored, so a database dump alone cannot brute-force a key. Keys are bound to one organization and carry an explicit scope set:
| Scope | Grants |
|---|---|
| content:read | Read content |
| content:write | Read and write content |
| developer | Build-console (developer) reach |
| admin | Organization-admin reach |
| * | All scopes (wildcard) |
Scopes are enforced on every gated route — an empty or missing scope is denied — and minting an admin- or *-scoped key itself requires an admin caller, so a low-privilege key cannot mint a higher-privilege one. Keys can carry an optional IP allowlist and expiry, and are revoked instantly with a delete.
# Mint a key. The raw secret is shown ONCE in the response; only a
# hash is stored. Minting an admin- or '*'-scoped key requires an admin.
curl -X POST https://api.polimorf.com/admin/v1/api-keys \
-H 'Authorization: Bearer <access_token>' \
-d '{"name": "Reporting sync", "scopes": ["content:read"]}'
# → { "key": "pk_live_…", "key_prefix": "pk_live_…", "scopes": ["content:read"] }
# Use it as a bearer token. Revoke any time: DELETE /admin/v1/api-keys/{id}.
curl https://api.polimorf.com/admin/v1/content/orders \
-H 'Authorization: Bearer pk_live_…'Token revocation & the audit log
Access tokens are stateless and short-lived; refresh tokens are stored as hashes and rotate on use. Because access tokens are stateless, logout-all, a password change, and suspension all work through a per-user cutoff: a recorded instant after which any token issued earlier stops decoding, even before it expires. One record revokes every outstanding session for a user without enumerating individual tokens.
# One call cuts every outstanding session for the signed-in user:
# refresh tokens are revoked AND a per-user cutoff is set, so already-issued
# access tokens stop decoding even though they have not expired yet.
curl -X POST https://api.polimorf.com/admin/v1/auth/logout-all \
-H 'Authorization: Bearer <access_token>'Changing your password and logging out everywhere both trip this cutoff automatically, so a leaked session cannot outlive the credential it was minted under.
Every content write and delete lands in a per-organization audit log. An organization admin can query their own org's trail — filtered by action, resource type, or actor, and paged in time order — for access review and compliance. Each entry records who acted (actor_type and actor_id), the action, the resource, the recorded change (diff), and when it happened. Cross-tenant isolation on this endpoint comes from the org-scoped session, not from a filter the query has to add.
On compliance
These are engineering controls, not a certification claim. For our published security posture and any formal commitments, see the security page.Where to next
- Core concepts — organizations, personas, and how isolation fits the bigger picture.
- Schema designer — the resources and fields your roles and row rules act on.
- API reference — the full auth flow, organization scoping, and endpoints.