Skip to content
Security & Tenancy
Admin & Reference
On this page

Security & tenancy

How KeyOne keeps one customer’s data away from another’s, and what it does not do — stated because a security document that overstates its coverage is worse than none.

One binary, one tenant

A running KeyOne binary serves exactly one tenant. There is no org switcher, no “my organisations” list, and no way to be in two at once. The tenant is fixed by keyone.yaml at boot and resolved once, not per request.

Isolation is enforced by Postgres, not by application code

Every tenant-scoped table has row-level security enabled and forced (FORCE ROW LEVEL SECURITY — 21 tables), with a policy of the form:

USING      (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid)

Every read and write goes through store.WithTenant, which sets app.tenant_id for the duration of one transaction. Two consequences:

  • A query that forgets a WHERE tenant_id = … still cannot see another tenant’s rows — the database refuses, rather than the code remembering.
  • FORCE means the policy applies to the table owner too, so a connection running as the owning role is not an escape hatch.

WITH CHECK covers writes as well as reads: a row cannot be inserted or updated into another tenant.

Who can do what

Roles are rep, analyst, admin, internal, carried in a signed JWT and checked by the router before any handler runs. The gates:

  • Analyst surface — hubs, decisions, queries, campaigns, reports, KeyChat, entity search, drill-down. Granted to analyst, and additively to admin and internal.
  • Admin surface — users, sources, engines, definitions, the reporting hierarchy, form-type authoring, and the whole ops section (run history, freshness, health, audit log, metrics). Admin and internal only, never analyst.
  • KeyLink — the field app runs its own chain, and enforces one rule structurally: seeing is not filing. An admin viewing as a rep may read everything and file nothing.

Personal surfaces are scoped to the caller’s own user id taken from the verified token — the notification inbox and KeyChat conversations have no route by which one user reaches another’s, and no admin override.

Sessions

Access tokens are short-lived and signed with a key that persists in the database, so a restart does not log everyone out and every instance behind a load balancer verifies the same tokens. Refresh tokens rotate on use, with a short grace window for concurrent refreshes; presenting a superseded token outside that window marks the whole token family compromised, and that verdict is never rolled back by a later valid refresh.

Credentials

Warehouse credentials never cross the API. The sources endpoint reports the connection’s non-secret configuration only — and specifically not the Postgres url, which carries a password inside it. The deployed keyone.yaml contains no secrets either: every credential is a ${VAR} expanded at boot, so the image is safe to push to a registry.

What KeyOne does NOT have

Named explicitly, because their absence used to be documented as presence:

  • No sub-tenant scope. There is no mechanism scoping writes to a region, a category, or a subset of stores. A user with a role has that role across the whole tenant. Field reps’ work feed is filtered to their assigned outlets, but that is a feed filter, not a write guard.
  • No dormant RLS flag. There is no KEYONE_SCOPE_RLS_ENFORCED or any environment switch that hardens isolation. What is described above is always on; there is no stronger mode to turn on.
  • No embed tokens and no policy-claim scoping of the query layer.

If any of those are needed, they are work to be done — not settings to be found.