Praxsuite

Security Model

Vincent Depassier · August 29, 2026

PraxQL — Security Model

Every request passes through six independent layers before any data comes back. They are worth understanding as a set, because each one fails differently and the differences are what you design your access model around.


Layer 1 — Authenticating the key

Request arrives
  → Extract the Bearer token from Authorization, or the x-api-key header
  → Match it against the stored credentials
  → Check the credential is Active
  → Check ExpiresAt has not passed, when set
  → Check the principal is Active

Anything failing here is 401 UNAUTHORIZED.

The key itself is never stored — only a one-way hash of it. That is why the portal shows a new key exactly once: nothing in the platform can recover it afterwards, and a leaked backup does not hand anyone a working credential.

Revoking a key is not instantaneous. Credential lookups are cached for up to two minutes, so a revoked key can still be accepted briefly. Plan for that window when you rotate under pressure; suspending the principal has the same delay, and only ending an end user's sessions takes effect immediately.


Layer 2 — Workspace isolation, structurally

Table GUIDs from refs are not resolved on their own. The authenticated workspace is bound into the lookup itself, so a GUID belonging to another workspace simply is not found.

This is worth stating precisely because of what it rules out: there is no cross-tenant policy to misconfigure and no rule an administrator can forget to apply. A key issued for workspace A cannot reach workspace B's data because the request that would return it cannot be expressed.


Layer 3 — Table permissions

Each credential carries table scopes.

Access level

Allows

read

query only

write

mutation only

readwrite

both

A table with no scope answers `403 SCOPE_VIOLATION`, not `404`, and the message does not confirm whether the table exists. A 404 would let someone map your workspace by probing GUIDs.

Where the scope comes from

There are two sources, and which one applies depends on who is calling:

Caller

Permissions come from

A server or publishable key (sk_live_, pk_live_)

the credential's own table and column scopes

An end user with a session JWT

the scopes attached to that user's gateway roles, merged as a union

The role path matters: an end user holding two roles gets the union of what both allow, so removing a permission means removing it from every role that grants it, not just the obvious one.


Layer 4 — Column permissions

Inside an allowed table, each operation on each column is separate:

Permission

Controls

Default on a new column scope

CanRead

may appear in select

on

CanFilter

may appear in where

on

CanSort

may appear in orderBy

on

CanGroup

may appear in groupBy

off

CanAggregate

may be used with an aggregate function

off

CanWrite

may be set by a mutation

off

They are separate because they leak differently. A column somebody may filter on but not read still tells them something — filter by salary, count the rows, and you have learned a range without ever selecting the column. Grant CanFilter deliberately, not as a side effect of granting CanRead.

A table scope with no column scopes defined allows every column. Define column scopes when a table holds something specific that should not travel — but note the asymmetry: defining them switches you from "everything allowed" to the defaults above, where writing, grouping and aggregating are off.

A column scope can also carry a default value template used on insert. It supports claim placeholders ({{claim:sub}}), it is injected server-side, and the caller cannot override it — which is how a row gets stamped with its owner without trusting the client to send one.


Layer 5 — Row filters

A row filter lives on the table scope and is injected into the where of every request touching that table. The caller cannot see it, remove it, or write around it — their conditions are ANDed with it.

{ "field": "ClientId", "op": "eq", "value": "client-guid-here" }

Every request this key makes carries that condition. The caller does not see other clients' rows, and cannot learn how many other clients exist.

Reads and writes, together or apart

A scope holds two filters, and the second one is optional:

Filter

Applies to

Row filter

query — and to insert / update / delete when no write filter is set

Write row filter

insert / update / delete, overriding the read filter

Leave the write filter empty and one rule governs everything: a row a caller cannot read is a row they cannot modify. That is the right default, and for a single-tenant-per-key setup it is all you need.

Set it when people share a container. A chat channel scoped Channel = X with write access lets every member edit and delete every other member's messages, because one filter only decides which rows are matched. Splitting them expresses the rule you actually wanted — read the whole room, change only what is yours:

   read filter   →  Channel eq X
   write filter  →  CREATEDBY eq {{claim:sub}}

`CREATEDBY` is the natural anchor. The gateway stamps it from the caller's verified token on every insert and refuses to let a client set it, so it is the one column a caller can never forge.

An UPDATE is verified again after it runs: if the rows it touched do not satisfy the write filter, the transaction is rolled back rather than committed.

Filters that resolve from the token

On a role scope, either filter can read a claim from the end user's token instead of holding a fixed value:

{ "field": "OwnerUserId", "op": "eq", "valueFromClaim": "sub" }

That resolves to the authenticated end user's own id at request time, which is what makes per-user isolation automatic rather than something every endpoint has to remember.

There is a shortcut for the common case. Setting the filter template to the literal __SELF__ tells the platform to find the table's first end-user column itself and build exactly the filter above. You get per-user isolation without naming a column — and without the filter breaking when somebody renames it.

If a table has no end-user column, __SELF__ has nothing to bind to. Add the column before relying on it.


Layer 6 — Guardrails

Some limits are absolute. No scope, plan or setting raises them:

Guardrail

Ceiling

Table refs per request

20

Columns in select

100

where conditions

50

Condition nesting depth

5

like / ilike pattern length

200 characters

Rows per query

1,000

Relation depth

5

Rows per insert

100

Columns in an update set

50

Query and mutation timeout

30 seconds

Others are per-scope, and start lower than the ceiling. A newly created table scope is issued with:

Setting

Credential scope

Role scope

AccessLevel

read

read

Max limit override

200 rows

100 rows

Max relation depth

2

2

Allow relations

on

on

Allow aggregations

off

off

Allow schema introspection

off

off

So a real credential does not get the absolute ceiling — and an end user's role starts lower still. If a query returns fewer rows than you asked for, read meta.limit: that is the scope talking, not the platform.

An override can be moved up to the absolute ceiling for that scope, never past it.


Masking, after execution

Once rows come back, each column is masked according to its scope's MaskingRule:

Rule

Effect

None

no masking

Partial

first and last character: J******e

EmailMask

local part hidden: j*****@gmail.com

PhoneMask

last four digits: ****7890

FullRedact

[REDACTED]

Masking runs after filtering, so the comparison still happens against the real value. Filtering on a masked column keeps working — which is the point, and also the reason CanFilter deserves its own thought.


Audit trail

Every request, successful or not, is logged asynchronously after the response is sent: workspace, credential and principal, end-user id for JWT callers, the full query body, status code, duration, rows returned, source IP and user agent, and the error when there was one.

Logs are in the Logs tab and cannot be deleted.


What this model does not cover

These six layers govern data. Two other axes apply at the same time and are configured elsewhere: which documents a credential may touch (Docs scopes) and which MCP tools it may call at all (tool permissions). None of the three can widen what another denies — granting a tool group does not grant the data it would read.