Security Model¶
Every query passes the same governance, whichever language it's written in (GraphQL, SQL, Cypher) and however it arrives (REST, gRPC, Arrow Flight, JDBC, WebSocket). There is no path around it.
The checks run in order. A request has to pass each one before the next is looked at.
Access Model covers how scope, capabilities and grants combine into a role's rights.
Layered Model¶
Layer 0 — Introspection filtering¶
A role is shown only the tables its domain_access reaches and the columns its visible_to rules allow. Anything else doesn't exist as far as that role can tell: it can't be queried, autocompleted or guessed at. The GraphQL schema, the SQL catalog and the query editor's schema browser all show the same narrowed view.
See Schema Visibility.
Layer 1 — Public access¶
A column published with scope public is served to every role in the org, even one whose domain_access doesn't reach the table's domain. That role sees the table with its public columns and nothing else. A column with scope listed shows its table's name and its own name in every role's catalog, and serves nothing. Nothing is listed or public until whoever registered the table says so. See Public columns.
Layer 2 — Domain access¶
Each role has a domain_access list of domain IDs, and a query that touches a table in any other domain is refused before it runs. This is the coarse ownership line. An HR role can't reach finance tables, however the SQL is written.
See Rights Model.
Layer 3 — Row-level security¶
Once domain access is settled, the role's row filter for each table is added to every SELECT as a WHERE predicate, evaluated against the raw data. A regional manager who runs SELECT * on the shared orders table still gets back only their region's rows.
Layer 4 — Column visibility and masking¶
A column whose visible_to list leaves out your role is dropped from the output. A masked column has its values replaced before they leave the server, by a regex, a constant or a truncation. That holds in every query language and every output format.
See Column Permission Model and Column-Level Masking.
Layer 5 — Predicate guard¶
You can't filter on a masked column: WHERE and HAVING refuse it when the query is parsed. Otherwise a caller could recover the real value by binary search, one filter at a time, even though every result it saw was masked.
Relationship governance (V002)¶
A SQL join has to follow a relationship that has been registered and approved, and any other join is refused. Each relationship carries a reason and a description that tell people and agents why the path exists. Treat this as governance policy rather than a security boundary. Layers 2 to 5 hold whatever the joins look like, so getting around the guard exposes nothing the role couldn't read with two separate queries. Attempts to get around it are logged.
There are two ways past V002. The first is a capability: a role holding ignore_relationships can join relations the catalog doesn't cover. Of the seeded roles only modeler has it, because its job is to work the model out, and analyst doesn't.
The second needs two things at once:
- Role flag:
relationship_guard: falseon the role definition (the default istrue). - Per-query opt-out: the comment
--relationship-guard=falsein the SQL.
Either one on its own does nothing.
High-security mode pins the guard. Under security.mode: high neither way works. ignore_relationships and relationship_guard: false are both ignored, and every join must be in the approved catalog. The redundancy is on purpose: a production role given the capability by mistake still can't step outside the model.
GraphQL skips V002. Its relationships come from the model, so they're approved already.
SQL and Cypher apply V002 by default, with the two-condition opt-out above.
pgwire applies the same check as SQL. The --relationship-guard=false comment is stripped before the query reaches the database.
The layers stack. A role with domain access, row filters and masked columns has all of them in force at once. Adding a source, a column or a relationship doesn't mean touching every rule, because each layer is configured on its own and applies to any query that reaches what it governs.
Rights Model¶
A role holds capabilities, and a capability opens a feature: querying, registering sources, editing the glossary, managing data quality checks. What the feature then reads or changes is limited by the role's grants. No capability reaches past them, and no role is a superuser. Bypassing relationship governance is a capability too, held by the standard Modeler role, and high-security mode ignores it.
Provisa ships standard roles, from Viewer up to Org admin, and you build your own roles as children of them. Access Model lists each standard role and what it adds.
Role Inheritance¶
A role names its parents on the Security page under "Inherits from", and it may have several. It holds the union of its own capabilities and every ancestor's. Grants and residency are unions over the ancestors as well, so a column, metric, function or webhook granted to an ancestor is granted to the child.
Domain reach does not pass down. Each role states its own reach, either ALL domains or a list, and its inherited capabilities apply within that reach. You can give a role reach only to domains you reach yourself.
A role's own row rule for a table stands. When it has none, the rules of its ancestors for that table are combined with OR. A parent with no rule contributes nothing, so adding a parent never lifts a row rule. A role with no rule anywhere in its ancestry reads every row.
Rate limits, row limits and session variables are properties. A role's own value is used; failing that, the first ancestor that sets one supplies it. The search is depth-first, in the order the parents are declared.
Nothing in the model subtracts. A role that should lack an inherited capability is built from narrower parents, and Access Model gives the additive form of each other use of a deny.
Roles: granted, derived and meta¶
Three kinds of role work together in Provisa's access model. The Roles page lists the first two together; a meta-role is never listed.
Granted roles¶
A granted role is assigned directly to a user. It holds rights: capabilities, domain access, masking entries, row filters. Inheritance gives the child every ancestor's capabilities and grants. Rights only add; there is no subtraction. To restrict a reader, grant to a composite that names both the role and a condition, or place a mask entry before the permissive one.
Expiring and unused grants¶
A grant Provisa holds can end. A grant given with an expiry is no longer held from that instant, on every request. A role can state inactivity_days: a grant of it that no request has depended on for longer than that, counted from the later of its last use and its creation, is removed. Roles that come from an identity provider's claims or role mappings are the provider's to time-limit, so set their expiry there. Derived roles and meta-roles never expire.
A request depended on a role when that role alone served it, or, when the request acts as several roles, when the role is granted a table the statement read. Acting as All does not count every held role as used. A grant's last use is recorded at most once a day.
Every 15 minutes each organization's sweep deletes the expired and unused grants and records each in the administrative trail as role.expired or role.removed_inactive. Wherever outgoing mail is configured, a single-organization deployment included, the holder and the organization's administrators are emailed auth.role_grant_notice_days days (default 7) before a grant ends, and the administrators are emailed when one is removed; where no mail is configured, the trail entry is the record. These notices are sent whatever a user's email preference says.
Derived roles¶
A derived role comes from a user's attributes. The org declares which attributes become roles (derived_roles), each with a description. For each declared attribute a whose value v the user's attributes supply, the user holds the parent role a and its value role a__v: region = eu gives region and region__eu. No value means neither role.
Attribute resolution matters. The org's own record of a member wins over what the identity provider supplies: both name the same attribute, the org's value is used, the provider's is marked overridden.
Derived roles carry no rights and are never assigned by hand. A grant list, a mask or a row rule names them exactly as it names a granted role; a request names them in X-Provisa-Role and acts as them.
On the Roles page each declared attribute is a parent role marked derived, with its value roles listed under it as their values are seen. Add role → Derived from attribute declares one. Its panel shows the attribute, its description, the values seen so far and every mask, row rule and grant that names it or one of its value roles. It has no grant controls. A value role's description is generated: "Held when the user's region is eu".
Declare them in configuration as a list:
derived_roles:
- attribute: region
description: The subsidiary a member works for; region__eu marks EU staff.
Think of a JWT: its claims are facts about the person. Derived roles turn chosen facts into roles, so a grant can address them without creating a role per combination.
Meta-roles¶
When a request names more than one role, Provisa builds an ephemeral meta-role identified as meta:<ids sorted, joined by +>. It is a single child of every role in the named set. The whole pipeline then runs with it as an ordinary single role.
Rights are the union of every member's. Row filters are the OR of each member's resolved filter; if any member that reads a table has no filter, the meta-role has none either. No filter means all rows. A meta-role is never stored, listed, or exported. It is rebuilt from its members when the model changes.
Each row's columns are masked and shown according to the roles whose row filter admitted that row: of those roles, the least restrictive reading of the column applies, and a role that did not admit the row contributes nothing to how it is read. Masks apply where the table is read, before any join or aggregate, so an aggregate reads the masked values. Writes are checked the same way: a column is written on a row only where a role granting that column admits the row.
The audit record names every acting role, not the meta-role id. Since a meta-role is never stored, it has no description of its own: wherever one is shown, its description is its members', each id with its own.
Descriptions¶
Every role and every row rule says what it is for. An access review reads who may see what and why. A role or a rule nobody can explain is one nobody can review, so the description is required: a role, a derived parent role or a row rule with an empty one is refused by the configuration load, the admin API and the UI, with the reason. The Roles and Row rules lists show each description, and their detail panels show and edit it.
A role has one description, not one per domain. A general role that does not describe its responsibilities within a particular domain means that domain needs a role of its own, usually a child of the general role, which inherits its rights and describes what it does there.
How a request names roles¶
A client sends one role, or a comma-separated set, in X-Provisa-Role. Each named role must be held. A role the identity is not assigned is refused by name.
Rights and data follow only the roles the request acts as: holding a role is not acting as it. One role is that role; two or more act as their meta-role. An acting role that brought every held role's rights with it would mean nothing, so a request gets least privilege and the audit record names exactly what acted.
Named derived roles enter as qualifiers. A qualifier has no rights, domain access, masks or row filters of its own, so it is no member of the meta-role's rights: as a member with no masks it would unmask every column, and as one reading no table it would end every OR of row filters. It enters only the grants: a grant or mask entry naming region__eu reaches a request that names that qualifier alongside a granted role.
An MCP agent never acts beyond the roles it was given. A remote agent names only roles its token's principal holds. A local stdio agent names only roles within the set pinned in mcp.role (PROVISA_MCP_ROLE), and acts as that set when it names none; any other role is refused by name.
An agent asks which role it acts as with the MCP tool describe_role. The answer is that role and what it is for (a meta-role with each of its members), and the roles the agent may switch to, each with its description. It never carries a row rule, mask, grant or condition. Provisa never tells an agent why a result was narrowed, and a result carries no "filtered" flag: a role's data is its whole view, and a reason for what is missing would invite probing for it.
What + means¶
+ appears in two places with different meanings in each.
In a grant entry. analyst+region__eu in a visible_to or to list means the reader holds all of them: analyst AND region__eu at once. An entry that is not a composite is a single role. Across a list's entries the rule is any one: a reader holds the grant if it holds any entry. AND-within, OR-across.
In a meta-role id. meta:analyst+engineer is the server's identifier for the meta-role of the set {analyst, engineer}. Its rights are the union of both. A client never names the meta-role id directly; it names the roles it holds, and the server builds the meta-role.
ABAC column rules as plain RBAC¶
An attribute-based policy asks: "can this user, with these attributes, see this column?" RBAC asks: "which roles can see this column?" Composite grants and derived roles make them the same question.
"Unmask this email only for an analyst in the EU" is a mask entry {to: [analyst+region__eu], mask: null}. Any reader holding both the analyst role and the region__eu derived role reads the column unmasked. No policy language. No role per combination.
The trade-off is computation over enumeration. A small role catalogue keeps maintenance low, but "who can see this EU email?" becomes a computed answer: composite entries are evaluated against each user's assignments and claims. Provisa can answer that in both directions because the model is one definition. The column editor's Check access (POST /admin/masks/check) runs that evaluation for a chosen set of roles.
Benefits¶
- An ABAC column rule becomes an RBAC grant entry.
- A small role catalogue instead of one role per combination.
- One definition coordinating every region's rules.
- Least privilege and an exact audit record per request.
Best practices¶
- Keep the role catalogue as small as the organisation allows.
- Inherit within one axis (seniority, clearance level). Compose across independent axes (role + region, role + department).
- Put the most specific mask entries first. An earlier entry wins even when the reader holds a later one.
- Check effective access with
POST /admin/masks/checkbefore deploying a combination in production.
Column Permission Model¶
Each column has a permission model controlling read, write, and masking access per role.
Three-Tier Visibility¶
| Tier | Condition | Result |
|---|---|---|
| Hidden | Role not in visible_to |
Column absent from schema and catalog |
| Masked | Role in visible_to; first mask entry it holds has a mask |
Column visible, value replaced in output |
| Unmasked | Role in visible_to; first mask entry it holds has no mask, or it holds none |
Full read access |
Public columns¶
Every column has a scope, set in the model file, the admin API or the Tables page. Scope says how far beyond its table's domain a column reaches, and visible_to alone says who reads it. In every scope, an empty visible_to means no role. A table editor may narrow a column's scope. Widening it serves the column to more roles, so it takes the right that granting it takes. A column being registered is compared with domain, so registering it public is a widening. On a column carrying a sensitive tag, any change of scope takes the right to govern sensitive data. A refused change names the column and the right.
| Scope | Served to |
|---|---|
domain (the default) |
Roles that reach the table's domain, as visible_to says. Outside the domain the column does not exist. |
listed |
The same roles. Every role in the organisation also sees the table's name and the column's name in the catalog, and nothing else about it. |
public |
The same roles, and also roles that do not reach the table's domain, as visible_to says. Every role in the organisation sees the names and descriptions. |
Publishing a column removes the domain condition for that column and nothing else:
- A role outside the domain is served the table with its public columns only. The table's other columns, and tables that publish nothing, stay out of its schema, its catalog and its SQL.
- The column's own
visible_tostill decides who: a public column granted to one role is public to that role alone. - Masks, row filters, row limits and the relationship guard apply as they do inside the domain. With no row filter for the role, the published columns are read unfiltered; a row filter on the table or its domain still applies.
- A relationship is served when both of its ends' columns are.
- A parameter column of an API table (a path or query argument) is not data and publishes nothing.
The rule is the same on every surface, because each one reads the role's compiled schema: GraphQL, SQL (HTTP, pgwire, Flight, JDBC), Cypher and Bolt, gRPC, REST and JSON:API, the Flight and MCP catalogs and the admin listings. A scope that is not one of the three values is refused when a column is saved.
Write Grants¶
Reading a column is controlled by visible_to, and an empty list means no role can read it. Registering a table puts the registering role and viewer in every column's visible_to. Writing is controlled per table by write_grants. A table holds no grants by default, so no role can write it.
A write grant states what one role may write to one table by one operation (insert, update or delete):
| Field | Purpose |
|---|---|
columns |
The columns an INSERT may list or an UPDATE may set |
rows |
The rows an UPDATE or DELETE may touch (a SQL predicate) |
check |
What every row an INSERT writes, or an UPDATE leaves behind, must satisfy (a SQL predicate) |
presets |
Columns the grant sets itself, from a session variable, a literal or the time of the write. The statement may not supply them. |
Leave rows or check out and the grant takes the role's read row filter on the table, so a role writes the rows it reads. "TRUE" means every row.
One admission decides every write on every surface, in this order: the role holds the write right; it holds a grant for the operation on the table; each column the statement writes is in the grant; the presets are added; rows is ANDed into the WHERE; and check is evaluated on the new rows before anything is sent to the source. A refusal names the table, role and operation.
Wider statements need wider grants. TRUNCATE needs a delete grant over every row. MERGE needs an insert and an update grant over every row. INSERT ... ON CONFLICT DO UPDATE needs an update grant over every row with no presets. COPY needs an insert grant with no presets.
GraphQL offers each role a mutation field for exactly the operations it holds grants for, taking the grant's columns. A child role inherits its parent's grants, its own first. A meta role holds the fold of its members' grants: the union of columns, the OR of rows and check, and only the presets all members share. In the admin UI, a table's Writes section lists and edits its grants.
Example¶
tables:
- source_id: hr-pg
domain_id: people
schema: public
table: employees
columns:
- name: id
visible_to: [hr, viewer] # every role built on viewer can read
- name: email
visible_to: [admin, analyst, viewer]
masks:
- to: [admin] # admin sees raw value (no mask)
- mask: {type: regex, pattern: "(.).*@", replace: "$1***@"} # every other reader sees it masked
- name: salary
visible_to: [admin, hr]
masks:
- to: [admin, hr] # admin and hr see real value
- mask: {type: constant, value: 0} # every other reader sees 0
- name: created_at
visible_to: [hr, viewer] # every role built on viewer can read
write_grants:
- role: admin
operation: update
columns: [email]
rows: "TRUE"
- role: hr
operation: update
columns: [salary]
- role: hr
operation: insert
columns: [email, salary]
presets:
- {column: created_at, source: now}
In this example:
email: admin sees[email protected]and can update it on any row; analyst and viewer seea***@example.comand cannot writesalary: admin and hr see the real value; hr can update it on the rows hr can read, because the grant states norows; every other visible role sees0created_at: every role built on viewer can read it, no role supplies it, and an hr insert sets it to the time of the write
Mutation Authorization¶
A registered mutation (remote GraphQL, OpenAPI, gRPC, Hasura) passes two checks. The role must hold the global write capability, and the command must be assigned to it by visible_to; an empty list assigns it to every role. A command has no writable_by list, and the configuration refuses one by name.
Whether something is a write is decided by its contract, not by what the caller says. A SELECT that calls a mutation-kind function is treated as a write and goes through the same two checks, so you can't run a mutation by dressing it up as a read. Marking a mutation read-safe takes the access_config capability and is recorded as a governance decision. No request can opt out.
Schema Visibility¶
Each role gets its own GraphQL schema. Tables appear only from the domains in its domain_access ("*" is all of them), and a column left out of visible_to for the role is left out of the SDL too. What the role can't read, it doesn't see.
Row-Level Security (RLS)¶
A row filter is a WHERE predicate for one table and one role, added after compilation and before execution.
rls_rules:
- table_id: orders
role_id: analyst
filter: "region = current_setting('provisa.user_region')"
description: Analysts read only their own region's orders.
The filter is ANDed into the query's WHERE clause, for reads and for UPDATE and DELETE alike.
A filter reads the request through current_setting('provisa.<name>'). The request binds user_id from the signed-in identity, role from the acting role, and every scalar claim in the token, lower-cased with dashes turned into underscores. provisa.user_id is always the signed-in identity, and no claim can replace it.
Hasura's session variables keep their names. X-Hasura-User-Id is hasura_user_id, read from the token's x-hasura-user-id claim (at the top level or inside the Hasura claims object, such as https://hasura.io/jwt/claims) or from the member's recorded hasura_user_id attribute. So an imported Hasura filter on X-Hasura-User-Id reads provisa.hasura_user_id.
Underneath the request's values sit the role's configured session_vars, which the request overrides. A role that inherits takes its ancestors' constants under its own, the nearest role winning for each name. user_id, role, region and org come from the request or the node and can't be configured as constants. In unsecured mode an x-provisa-session-<name> header binds <name>. A variable bound nowhere is NULL, and a filter on it matches no rows.
A role constant works the other way round from a derived role. A derived role turns a value a member holds into a role. A role constant hands every request made as the role a value, which a row rule reads just as it reads a claim. The constant can be a ${env:NAME} or ${secret:NAME} reference, resolved by the same resolver as every other reference when a query uses it. The model keeps the reference, never the value, and a reference that resolves to nothing fails the query and names the constant. Once resolved, the value is part of the query: Provisa's own records of it are covered by central redaction, but a federated source's own logs are beyond Provisa's reach.
Column-Level Masking¶
Masking is defined as an ordered list on each column. The first entry the reader holds decides what it sees.
| Mask Type | Supported column types | SQL expression |
|---|---|---|
regex |
String (varchar, char, text) | REGEXP_REPLACE(col, pattern, replace) |
constant |
Any | Literal value (NULL, 0, a custom string) |
truncate |
Date/Timestamp | DATE_TRUNC(precision, col) |
Each entry has a to list naming the readers it targets, and an optional mask. An entry with no mask means unmasked for those readers. The list's last entry may leave out to; it is for every other reader, and a list holds at most one such entry.
The mask expression is pushed into the SQL SELECT projection. The database returns the masked value; unmasked data never crosses the wire for masked roles. Masked columns are also blocked from WHERE and HAVING clauses (Layer 5 predicate guard) to prevent value inference through filtering.
A mask covers every name of the column, not a related column. A column read by its alias or its physical name is masked alike. A mask does not hide a key that an approved relationship joins to a column the role can see: the join compares the real values, so the related column shows what the masked key holds. Mask the related column too, or don't approve the relationship for that role.
No entry held means unmasked. A reader that holds no entry reads the column unmasked. End a list with an entry that has no to when every reader should have some mask applied. The column editor warns when a list has no such entry (unlisted_unmasked).
Deny by order. An earlier entry wins even when the reader also holds a later, more permissive one. To ensure contractors always see a masked salary regardless of their other roles, put the contractor entry before the unmasked entry. A granted role never loses what its parent grants through inheritance, but a mask entry placed earlier in the list applies first. That is how restriction works here.
Meta-roles. A meta-role's chain includes every member's roles, their ancestors, and any named derived roles. An entry granted to any member reaches the meta-role. An entry before it, held by another member, still applies first.
Checking access. POST /admin/masks/check accepts a mask list and an optional set of roles, and returns which entry applies for those roles plus any entries no reader can reach. Gated by masking_config.
# email column: analyst+region__eu sees raw; every other reader sees it masked
masks:
- to: [analyst+region__eu] # composite: analyst AND in EU region
- mask: {type: regex, pattern: "(.).*@", replace: "$1***@"} # every other reader
# hire_date column: hr sees raw; every other reader sees the year only
masks:
- to: [hr]
- mask: {type: truncate, precision: year}
Governing Action Responses¶
What a tracked function or a webhook returns is governed like a table's rows. Provisa binds the returned rows as a relation whose columns are the action's declared output: output_columns for a function, inline_return_type for a webhook, or the object properties of a function's array return_schema. That relation then goes through the same governance stage as any table read, over a VALUES CTE holding the rows, so row security, masking and visibility aren't reimplemented for actions.
Three rules apply, in the same order as for a table:
- Row filter. The role's RLS rule saved against the action by name (
upsertRlsRulewithactionName), or the action's domain rule when the action has none of its own. The predicate is checked against the contract when it's saved, as a table rule is. - Column visibility. A contract column that declares
visible_tois dropped for any role the list leaves out. A column that declares none is part of the action's public shape and stays. - Masks. A contract column carries
masks, the same ordered list a table column carries.
Role inheritance resolves the chain for all three. If a response returns a column outside its declared contract, the call fails with a 502 rather than passing it through ungoverned. An action that declares no columns returns a scalar, which has nothing to bind, and comes back as it is. The admin test-invoke reports which filters, exclusions and masks applied, and the governed statement goes to the query audit log.
Sampling¶
Every role gets a sample of the results, 100 rows by default, unless it holds the full_results capability. Set the size with the PROVISA_SAMPLE_SIZE environment variable.
Audit Logging¶
Every query that touches a domain asset is written to query_audit_log. The log belongs to the org, not the platform: the platform's own registry of orgs, users and invitations holds no audit records. Each org's log is a table in that org's schema, in the store the org names for its record in each region it selects. An org that declares no regions keeps it in the deployment's control-plane database, beside its model.
A row records tenant_id (the org), user_id, role_id, a SHA-256 hash of the query text, the query text itself (encrypted), table_ids and table_names, source, status_code, duration_ms, the route and row count, what was enforced on the statement, and logged_at. The text is encrypted with the org's key if the org has set one, and with the deployment's key otherwise. A deployment with no encryption provider stores it unencrypted. Either way it never appears in the queryable ops tables, and it's decrypted only for authorized admin reads.
The log is append-only in the database itself, where PostgreSQL rules turn DELETE and UPDATE on query_audit_log into no-ops. Two indexes, (tenant_id, logged_at) and (user_id, logged_at), keep org-wide and per-user compliance queries over a time range fast.
Rows are kept for good. Provisa has no retention setting and never purges the log, and since DELETE does nothing, clearing old rows means an operator dropping or archiving the table's data outside Provisa.
Redaction¶
What Provisa records about its own work passes through one list of redaction rules before it is written anywhere. Text a rule matches is replaced with [REDACTED:<rule>], naming the rule that matched.
The list has four parts:
- Built-in patterns. Card numbers that pass the Luhn check, US Social Security numbers, bearer tokens, Provisa access tokens, AWS, GCP, Azure, GitHub and Slack keys, and private keys. These are a starter set, not a guarantee.
- The deployment's rules, which apply in every organization. Changing them takes the
platform_settingsright. - The organization's own rules. Changing them takes the
org_settingsright. - The automatic rule over the organization's secrets: every value Provisa's own store holds for the organization, every
${secret:NAME}the organization's model and settings name (on any secrets backend), and each${secret:…}or${user:…}value from the moment it is first resolved. No rule has to name these.
A rule is either a named regular expression or a named ${secret:NAME} / ${env:NAME} reference, which matches the value the reference resolves to. A reference that does not resolve is logged at ERROR by name and shown as unresolved; the other rules still apply. Values shorter than four characters are not matched.
Provisa is not responsible for knowing every sensitive format. The built-ins cover common shapes; an organization adds patterns for its own formats, such as employee numbers, account identifiers or internal hostnames. Redaction is best effort: a value written in a shape no rule describes passes through.
The rules are on the Org Secrets page, under Redaction rules. The page lists the built-ins, the deployment's rules (editable with platform_settings), the organization's rules, the automatic rule's secrets by name only, and any reference that does not resolve. The rules are loaded when the server starts and when an organization's runtime is built, and reloaded when the model or the settings change and when an organization secret is stored or removed.
The list applies at every exit Provisa writes:
- every span, as one redacted copy handed to every exporter, and the recent-spans view;
- every log record in Provisa's OpenTelemetry log pipeline, and every line a logging handler writes, standard output and container logs included;
- the audit log's query text, before it is hashed and encrypted, so neither the text nor its hash discloses a matched value; and the administrative trail's detail;
- the error messages a client receives over HTTP, pgwire, gRPC, Arrow Flight and MCP.
Telemetry and client errors are not tied to one organization when they are written, so they are redacted by every organization's rules together. Two errors are passed through unredacted: an HTTP error body larger than 1 MiB, and the error of an Arrow Flight stream that fails after it has started sending.
A federated source keeps its own logs, which Provisa does not write and cannot reach. Records a source makes of the statements Provisa sends it are that source's to protect.
Statistics over MCP¶
The MCP statistics tools (describe, correlate, regress, compare, forecast) run the SQL an agent defines through the governed pipeline as the agent's role, so row rules, masks and redaction apply to what they analyze. Stats tools return only results, so the rows behind an analysis never enter the model's context: data minimization by default.
Access control is what limits what an agent can learn. To keep identities out of an agent's reach, grant its role only the columns that identify no one; a role that can read a column can aggregate it, and aggregates over small groups can still identify individuals.
Sensitive-column coverage¶
The sensitive_coverage view in the meta domain lists every sensitive column of the organization with the fake and synthetic rule it declares and a status: covered, no_fake, or inadequate when one of Provisa's adequacy checks failed. The checks look for a fake that leaves real values unchanged, a fake whose kind does not fit what the column holds, and a fake or rule that reads another sensitive column's real value. A data steward sees the columns with no fake in each environment's detail, and a Test (fake) environment refuses to serve data until every sensitive column has one. The checks and the view are described in Building test environments.
Judgments under review¶
Governance choices rest on judgment, and judgment can be wrong or go out of date when the facts it rested on change. Every governance judgment can carry a why: a tag on an object, a column's fake and synthetic rule, a mask entry, a row rule and a write grant. A why holds the reason relied on, the purpose, the named bases cited and a review-by date, all optional. On a tag assignment the reason is the assignment's own reason; on a row rule the purpose is its required description. Provisa records who made the decision, when, and the profile evidence it was made on: the table's latest profile run and a digest of it. It records them at each save that changes the decision or its why. A save that leaves the why out keeps the stored one, and a model file carries each why in a why: block.
Named bases are the context the data cannot show: a regulation, a policy, a strategy. A judgment cites them by id from a short list (/admin/judgments/bases). A holder of access_config declares a basis changed with a note, and every judgment citing it that was decided earlier is dated at once.
The review runs every hour for each organization, at the end of each profile run for the profiled table, and at once when a basis is declared changed. It puts what it finds in one queue:
| Category | Found when |
|---|---|
| Wrong | an adequacy check on a sensitive column's fake fails, or a sensitive column's synthetic rule copies real values |
| Dated by data | the profile drifted on the decided column (the profiler's own thresholds), its plausible type, family or data type changed, a column was added to the table, the decided column was removed or retyped, or the decider is no longer a member |
| Dated by context | a cited basis was declared changed after the decision, the review-by date has come, or a tag assignment's expires_on has passed |
| Undecided | a sensitive column has no fake, or a column was added to a table that has a sensitive tag or a fake |
| Worth a look | a source credential is stored as a value, not a reference; a Test environment's source reads prod's connection; no authentication is configured; the bootstrap administrator is unclaimed; a listener serves without TLS; a role or source reaches every domain; an expired tag assignment is still in force |
The last category is observed, not enforced: nothing in the review refuses anything. GET /admin/judgments/findings lists the open findings a caller may see; a finding on a sensitive column needs sensitive_data, and any other needs view_governance. Reaffirm (POST /admin/judgments/findings/{id}/reaffirm) records the judgment again with today's evidence and needs the right that edits the decision. A posture finding is dismissed with a note (.../dismiss, access_config). A finding the review no longer sees is cleared. For audit evidence, the judgment_review view in the ops domain joins every finding to its judgment's why and is queried under role and domain access.
Rate Limiting¶
You set per-role limits in provisa.yaml: requests per second, concurrent SSE subscriptions and concurrent Arrow Flight streams. They're checked at the API, before anything is compiled or run, and a request over the limit gets HTTP 429 with a Retry-After header.
Natural-language queries (POST /query/nl) have a limit of their own, nl.rate_limit, in requests per minute per role. A request over it is turned away before any LLM call is made.
The counts live in Redis (cache.redis_url) as sliding windows, with nothing held per instance, so a limit holds however many Provisa instances you run.
Query Complexity Limit¶
A rate limit caps how many requests a role sends. The complexity limit caps what one statement may ask for.
Every statement has a complexity score: the relations it reads, its joins, the columns it selects (a * counts as the columns it stands for), and the queries nested inside it. A relation read from a remote API source (OpenAPI, remote GraphQL, remote gRPC) counts as 10, because each read spends a budget the remote system sets for everyone using that source.
The limit is max_query_complexity in a role's rate_limit. The org-wide setting limits.max_query_complexity is a ceiling for every role: a role may set a lower limit, and a higher one has no effect. Neither is set by default.
The score is measured after a request is parsed and before it is governed or run, so one limit holds on every interface a statement can arrive on: GraphQL, SQL over HTTP, pgwire, Arrow Flight, gRPC, Cypher, JSON:API and MCP. A statement over the limit is refused with its score, the limit and what it asked for. HTTP interfaces answer 413. The refusal is recorded as a policy denial.
Authentication¶
Pluggable auth providers:
| Provider | Credential | Use Case |
|---|---|---|
none |
X-Provisa-Role header | Development only |
basic |
Local accounts (bcrypt) + session JWT | Self-contained deployments |
firebase |
Firebase ID token | Production |
keycloak |
Keycloak JWT | Enterprise |
oauth / oidc |
OIDC JWT | PingFed, Okta, Azure AD, Auth0 |
ldap |
Directory username and password (LDAP or Active Directory) | Enterprise directories |
saml |
SAML 2.0 signed assertion, then a session JWT | Enterprise single sign-on |
simple |
bcrypt + JWT | Testing |
ldap signs a user in by search-then-bind: Provisa binds as a service account, finds the one entry the username names, then binds as that entry with the presented password, so the directory checks it. The user's groups become their roles. The account and its groups are read again each time a session token comes back, so a user removed from the directory, or from a group, loses that access before the token expires. ldaps:// and StartTLS are both supported.
saml is SP-initiated Web Browser SSO. The response's XML signature is verified against the identity provider's metadata, and claims are read only from the signed element. Issuer, Audience, the ACS URL, expiry and InResponseTo are all checked. Unsolicited (IdP-initiated) responses, encrypted assertions, documents with a DOCTYPE, multiple assertions and SHA-1 signatures are refused. A SAML session is not checked again with the identity provider: a user removed there keeps access until the session token expires.
Role mapping: identity claims → Provisa role via configurable rules. The assignments_source field controls where role assignments come from: claims reads them from JWT token claims (default), provisa reads them from Provisa's internal assignment store. A multi-tenant deployment (multitenancy: true) grants roles itself, per org, so it must use assignments_source: provisa: with claims it does not start, and a settings change that would produce that combination is refused. A single-tenant deployment may use either.
A superuser configured in provisa.yaml (username plus a password from an env secret) always receives the admin role and all capabilities regardless of the configured provider, a bootstrap path for initial setup.
Surfaces and credentials¶
Every surface signs callers in through the same provider, so a credential that works on one works on all of them, wherever the protocol has a place to carry it.
| Surface | Password | Provider token | Personal access token | Client certificate (mTLS) |
|---|---|---|---|---|
| HTTP (REST, JSON:API, GraphQL) | Authorization: Basic |
Authorization: Bearer |
Authorization: Bearer |
via terminating proxy |
| pgwire | password field (cleartext or SCRAM) | password field, OIDC deployments | password field | yes |
| Bolt | basic scheme |
bearer scheme |
bearer scheme |
yes |
| Arrow Flight | — | token in the handshake or ticket payload; authorization: Bearer call header on list_flights, get_flight_info and get_schema |
same | yes |
| gRPC | — | authorization metadata |
authorization metadata |
yes |
Airport (DuckDB airport extension, opt-in) |
— | authorization: Bearer (the secret's auth_token) |
same | — |
| MCP | — | Authorization: Bearer |
Authorization: Bearer |
via terminating proxy |
The DuckDB airport extension can only send the bearer auth_token of its secret. A DuckDB client puts a provider token or a personal access token there and acts as the role its identity resolves to. Any client that can set call headers may ask for one of its roles, or a comma-separated set of them, in x-provisa-role.
Every surface tells you which kind of refusal you got. A missing, rejected or unusable credential is an authentication refusal: HTTP 401, gRPC UNAUTHENTICATED, Arrow Flight and Airport Unauthenticated. A valid credential asking to act as a role its identity doesn't hold is an authorization refusal, and it names the role: HTTP 403, gRPC PERMISSION_DENIED, Arrow Flight and Airport Unauthorized. One exception comes from the Arrow client library, not the server. When Flight's get_schema is refused, a Python client sees a generic Arrow error that carries the server's reason but not the kind; list_flights, get_flight_info and do_get do deliver the kind.
A — in the table means the protocol has no username field to pair a password with, and the token forms cover it. pgwire is the opposite case. Its startup packet has one secret field and no scheme, so the secret itself decides the method: a personal access token is recognized by its prefix, the secret is read as a bearer token when the configured provider issues tokens, and anything else is a password. That choice is made once, and a credential the chosen validator refuses isn't tried against another.
Personal access tokens¶
A personal access token is a long-lived bearer secret you mint for a client that can't do an interactive login: a script, a BI tool, a driver. It carries its own org and role, and every surface checks it with the same validator, so no surface needs to know what one is.
On the wire it is provisa_pat_ followed by 43 URL-safe base64 characters. The prefix is what sends a presented secret to the token store instead of the identity provider, and it makes a leaked token easy to find in logs and repositories.
Only the SHA-256 of the secret is stored. You see the secret once, when it's created, and it can't be recovered afterwards; the list of your tokens shows a display prefix and dates, never a working credential. You mint, list and revoke your own tokens with POST /auth/tokens, GET /auth/tokens and DELETE /auth/tokens/{token_hash}, or in the tokens section of your profile in the admin UI.
A valid token resolves to its owner's account, with user id, email and display name, so an audit row or usage report written under a token names the person rather than the credential. Which of their tokens acted is recorded separately, in raw_claims["token_name"]. A token can carry an expiry, after which it's refused, and removing a user's membership revokes their tokens with it.
SCRAM-SHA-256 on pgwire¶
Under the basic provider, setting auth.scram: true makes pgwire advertise SASL (authentication code 10) with the SCRAM-SHA-256 mechanism, so a password is proved rather than sent. Channel binding (SCRAM-SHA-256-PLUS) is not offered.
SCRAM needs an RFC 5802 verifier, which cannot be derived from a bcrypt hash. A verifier is written whenever a password passes through in plaintext (signup, login, password change, admin reset) so a deployment that turns SCRAM on collects verifiers as its users next authenticate, and each user's first SCRAM connection follows their next password entry. A user with no verifier yet is answered with a mock exchange indistinguishable from a real one, so the wire does not reveal who has migrated.
Mutual TLS¶
Client-certificate verification moves the first check to the TLS handshake: a caller without a certificate signed by the deployment's CA never reaches the credential layer. It is available on pgwire, Bolt, gRPC and Arrow Flight: the four transports that terminate their own TLS.
| Variable | Meaning |
|---|---|
PROVISA_MTLS_CLIENT_CA |
PEM bundle of the CA(s) permitted to sign client certificates |
PROVISA_MTLS_MODE |
required (the default once a CA is set) or optional |
PROVISA_MTLS_BIND_PRINCIPAL |
When true, the certificate's common name must equal the username the connection then authenticates as |
Per-protocol overrides follow the same naming as the TLS settings. Nothing is inferred: a mode set without a CA refuses to start, and an unrecognized mode refuses to start rather than being read as the safest neighbour. A deployment that believes it requires client certificates and does not is worse off than one that fails to start.
TLS versions¶
Every listener Provisa terminates (the HTTP API and UI, pgwire, Bolt, MCP, gRPC and Arrow Flight) accepts TLS 1.2 and TLS 1.3, and refuses TLS 1.0 and 1.1. Provisa sets no protocol version or cipher list of its own. The HTTP, pgwire and Bolt listeners use Python's ssl server defaults (minimum TLS 1.2). gRPC and Arrow Flight use gRPC's defaults (minimum TLS 1.2). The versions are not configurable.
Login throttling¶
Password guessing is protocol-independent: the same account can be hammered over HTTP, pgwire and Bolt. The counter therefore lives at the credential-validation layer, not on any one surface, so a lockout earned anywhere is enforced everywhere.
It is on by default (five failures in five minutes locks the subject out for fifteen minutes) and is tuned under auth.login_throttle. A locked-out subject is refused before the credential is checked at all, and a successful authentication clears that subject's history.
The key is the principal the protocol carries. A bearer-only surface carries no principal, so the key is a digest of the credential itself; what that stops is one bad token being replayed without limit. The store is per process, so a deployment running several API workers allows up to max_attempts per worker. The throttle is a brake on guessing, not a distributed quota.
Role probing¶
Naming a role the caller does not hold is treated as a detected security event of high severity, on every surface: the HTTP API, MCP, pgwire, Bolt, Arrow Flight, the airport Flight service and gRPC. A legitimate client names the roles it was given; one that names another is either broken or probing.
The request is refused. The caller is told only "not permitted", nothing about whether the role exists, and the connection or session ends where there is one: the pgwire connection, the Bolt session, the gRPC call, the MCP session. The exception is the HTTP API with any credential but a personal access token: the browser presents its session or its identity provider's token there, so the refusal keeps naming the role.
What else happens depends on the credential and the transport:
| Credential | Transport | Suspended |
|---|---|---|
| A demo sandbox visitor's token | the HTTP API | nothing |
| Personal access token | any | the token |
| Token signed by an external identity provider | MCP, pgwire, Bolt, Flight, gRPC | the principal's token access to the org |
| Token signed by an external identity provider | the HTTP API | nothing |
| A session or password this deployment checks | any | nothing |
| A local MCP session (no bearer) | MCP | nothing; the event goes to the operator's log |
The HTTP API is the exception because the browser presents the same bearer token an API client would, so the server cannot tell them apart, and a tab still naming a role the user has since lost (after an administrator changes their roles, or an org switch) is routine. MCP and the other transports are never a browser session. There the event is refused, recorded and notified like any other, and only the suspension is withheld. A demo sandbox visitor's token is a personal access token, but it is the visitor's browser tab's only credential, so on the HTTP API it is treated as a session is: refused and recorded, never suspended.
A suspended token is refused as a revoked one is. A principal whose token access is suspended is refused on MCP, pgwire, Bolt, Flight and gRPC in that org, and can still sign in to the UI. Only an administrator lifts a suspension; nothing lifts it on a timer. Team → Suspended access lists each suspension with the event behind it and lifts it (GET /admin/orgs/{org}/suspensions, POST /admin/orgs/{org}/suspensions/lift).
Each event is written to the org's admin trail (action security.role_probe) with the principal, the credential id, the client address, the transport, the endpoint or tool, the role named, the roles held, the time, and the ids of the principal's most recent audited statements. Their text stays in the query audit log and is read under the view_governance right.
The org's administrators are emailed where the deployment sends mail. The event is also posted as JSON to the operator's security webhook when security.webhook_url is set. What leaves the org names the principal, credential id, transport, endpoint, role named, time and severity, with a link to the admin view; it carries no statement and no held role. A notice that cannot be delivered is logged and never undoes the refusal or the suspension.
Naming the org¶
Under multitenancy every request names the org it is for. Belonging to exactly one org does not name it: a request that names none is refused, and the refusal says what to send. Each surface names an org this way:
| Surface | How a client names the org |
|---|---|
| HTTP (UI, REST, GraphQL) | The org's own hostname, acme.provisa.dev, or the X-Org-Provisa header on the control-plane host |
| pgwire | The database name (psql -d acme, a BI tool's database field), or the org's own hostname dialed over TLS |
| Bolt | The database name, <org>.provisa_<role> (or <org>.provisa_ops_<role>), or the org's own hostname dialed over TLS |
| Arrow Flight | "org" in the ticket |
| gRPC | The x-provisa-org metadata header |
| MCP (HTTP transport) | The org's own hostname, or the X-Org-Provisa header |
| Python client | org= on ProvisaClient, connect() and adbc_connect(); ?org= in a SQLAlchemy URL |
A pgwire client that dials a hostname over TLS sends it in the TLS ClientHello, and Provisa reads the org from there. When the hostname and the database name both name an org and the two differ, the connection is refused; neither is preferred. The database name provisa, the default, names no org, and a BI tool connected to an org shows
that org as its database. On Bolt, SHOW DATABASES lists one <org>.provisa_<role> per org the
user belongs to; a bare provisa_<role> is refused under multitenancy, and a session stays in the
org it first named.
A name is a request, not a grant. It reaches the same resolver everywhere, which refuses an org the authenticated principal is neither a member of nor holds the cross-org right for.
A personal access token is issued for one org and names it itself. It opens that org only: a request naming a different org is refused, even when the token's owner belongs to both.
A single-tenant deployment has no org to name. No surface reads a hostname, a database name or a header for one there.
Secrets across organizations¶
An organization's secrets are read only by that organization. In Provisa's own store each secret is held under its organization. In a central service (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) on a deployment of many organizations, every read is made inside the reading organization's prefix, and a reference that would leave it is refused. The prefixes and the steps to move existing secrets under them are in Secrets.
Telemetry across organizations¶
On a deployment shared by many organizations, the ops signals ("ops"."traces", and on the Trino engine the metrics and logs beside it) are one store every organization's work lands in. Each span, metric point and log record records the organization it was written for, kept as tenant_id, and every read of the signals answers only the reading organization's rows, whatever the role. A record made outside any organization's work (the server's start, for one) belongs to none and is read by none. The platform administrator holds no data access in any organization and reads none of them through Provisa.
The ops signals are kept indefinitely on both engines, as the audit log is: compaction rewrites the files they are stored in and removes none of their rows. On Trino they are the otel catalog's Iceberg tables. On a native engine (DuckDB) the server lands its own trace records as Parquet files in its telemetry folder and the engine reads them under the same name, "ops"."traces", with the refresh lineage view "ops"."mv_refresh_lineage" over them; both are read under the same per-organization rule.
The signals are kept in the clear in their store. A workload that needs more than a per-organization boundary inside one deployment runs on a dedicated deployment; a highly sensitive or regulated one is self-hosted.
Background work across organizations¶
A process that serves many organizations runs each one's background work as that organization. Each organization's change processing (the event loop that lands source changes and refreshes the views over them), its replica builds and the table triggers they fire belong to its own runtime: a change is processed by its own organization's job, reads its source with that organization's address and credential, and writes that organization's store. Each environment of an organization holds its own PostgreSQL logical replication slot and publication, named for the organization, the environment and the source, so two organizations that name a source alike on one database each read their own changes.
Data residency¶
On a deployment with regions, a table can name one region as its home, and its personal data is stored and processed there.
- Only the home region builds and refreshes the table's replica. Every other region reads that replica in place, with the reader's row and column rules applied in the home region, so only what the reader may see leaves it.
- A home region whose replica is not built, or whose stores cannot be reached, refuses the read (
query.home_region_unavailable). The source is never read live from another region instead. - A draft is previewed only where it lives. A draft table that names a region is read for its steward's preview only on that region's nodes and refused elsewhere; the copy the preview lands is kept there for its release. A copy any region holds of a table, draft or published, whose home is now another region is retired and then dropped.
- Setting, changing or removing a table's region needs the
data_residencyright for the regions involved, on top of owning the table through its domain. A move can pass through draft, so the table is served nowhere in between.
Row and column rules written on the answering node's region (current_setting('provisa.region')) and on members' region attribute decide what crosses between regions. Regions describes regions, homing and these rules in full.
Deployment Hardening¶
Store credentials and where the stores listen¶
The bundled stores have no default credentials. The PostgreSQL model store, Redis, MinIO, Grafana's admin account and the Trino cluster's shared secret each take a value generated for the deployment:
- The launchers (
provisa start, the desktop and server installers) generate them once and keep them in~/.provisa/store-credentials.env(mode 600). A value already in the environment is used as given, and a secondary node, which uses the primary's stores, refuses to start without the primary's. - The Terraform modules generate them with
random_passwordand give the same values to every node. They are held in the Terraform state, so protect the state as you would the passwords. - The Helm chart sets none. It requires a Secret you create, named by
credentials.existingSecret, holdingpg-password,redis-password,minio-access-key,minio-secret-key,grafana-admin-passwordandtrino-shared-secret.
Redis requires its password, and the PostgreSQL role's password is set again at each start so a volume made earlier follows the deployment's current one. The stores, the federation engine and the telemetry services publish their ports on one address, PROVISA_STORE_BIND_IP: 127.0.0.1 on a node of its own, or the primary's private address when secondaries connect to it. Grafana's admin account takes the generated password; the compose stack lets anonymous visitors view dashboards, and the Helm chart admits no anonymous visitor.
Transport encryption¶
Every listener Provisa terminates serves TLS (see TLS versions). A server node uses the certificate in PROVISA_TLS_CERT/PROVISA_TLS_KEY, or generates a self-signed one. The Helm chart serves TLS by default (tls.enabled: true) from the kubernetes.io/tls Secret tls.secretName names.
The embedded PostgreSQL¶
The desktop and native tiers run PostgreSQL embedded. On macOS and Linux it listens on a unix socket inside its data directory (mode 700), which only the account that owns the directory reaches. Over TCP (Windows, where it listens on 127.0.0.1), every connection must give a password generated for the data directory and kept in it (provisa.password, mode 600). It is never started as root.
The federation engine (Trino)¶
- Between nodes. A coordinator and its workers authenticate each other with the deployment's generated shared secret (
internal-communication.shared-secret). Encrypting their traffic is an operator option, off by default: off, it stays inside the deployment's private network or cluster. Turn it on where a compliance or zero-trust requirement asks for encryption inside the deployment. Trino documents it as slowing shuffle-heavy queries (distributed joins, aggregations, window functions) by 10% to over 100%. On a compose deployment it is the settingengine.trino_internal_tls(Deployment settings → Engine), with the engine network and the coordinator's pinned address beside it, and it applies at the next start. Under the Helm chart it is the valuetrino.internalTls; the chart generates the CA and certificate the nodes use, keeps them across upgrades, and addresses each node by its pod DNS name. The hosted platform's engines are single-node coordinators, with no traffic between nodes, and refuse it. - Spooled data. Fault-tolerant execution encrypts the exchange data it spools with a key generated for each query and discarded when it ends (
fault-tolerant-execution.exchange-encryption-enabled=true, set explicitly). Legacy spill to disk is not used. - Organizations on one engine. Each organization's catalogs are its own, named for the organization, and each organization's statements run in its own resource group.
- Catalogs, and the credentials in them, live only in the engine's memory. Provisa creates every Trino catalog at runtime, and each holds its source's connection properties, passwords included. Every engine Provisa runs keeps them in memory (
catalog.store=memory): no catalog is written to the engine's disk, and none is mounted from a file. A coordinator that restarts comes back with no catalogs; each Provisa process notices on its next query, and the engine watcher within a minute, and recreates the catalogs it serves. A Kafka source with SASL credentials needs one file: Trino's Kafka connector reads its client settings only from a file, so Provisa writes them, password included, tokafka-client/<catalog>.properties. The file is readable by Trino alone. Where Provisa runs as root, as in the container stack, it is Trino's user's (mode 0600 in a 0700 directory). Where Provisa runs as another user beside Trino's container, as a native install does, it is given to Trino's group instead (mode 0640 in a 0750 directory): the group isPROVISA_ENGINE_FILES_GID, 1000 by default, Provisa's user must be a member of it, and a write by a user outside it is refused by name. A code host's client key is written the same way, and its certificates, which are public, are readable by anyone (mode 0644). In the container stack the directory is a memory-backed volume the API and the engine share. On the hosted platform's engines it is the engine's own Kubernetes Secret, held in the node's memory and readable by Trino's group alone (mode 0440). Either way the file never reaches a disk. On the hosted engines a Kafka source must name a schema registry: one whose topics' columns only the model declares is refused by name when it is saved or applied, since its table descriptions would need a directory the running engine cannot be given. Starting the stack removes the catalog and Kafka client files earlier releases left on disk.
Sources on a shared deployment¶
On a deployment that holds many organizations, an organization's sources may reach only public addresses: never the platform's private network, where its model store, cache and object store are. The check applies when a source is saved, by the UI, the admin API or a configuration apply, and again at every connection and every HTTP redirect. A demo sandbox's sources are further held to the address they were checked at. The deployment's own organization, whose sources the operator configures, is not held.
Encryption at Rest and the Organization's Own Keys¶
Encryption under an organization's own key is available, and it is off until the organization turns it on. Nothing is encrypted under a key of the organization's unless an organization admin creates one.
The organization's encryption key wraps the organization's secrets, its encrypted columns and its audit query text. An organization admin creates it under Admin → Org Secrets → Org Key, or with PUT /admin/org-encryption: with no body Provisa generates the key, which is never shown, and a key_b64 body supplies 32 bytes of your own. GET /admin/org-encryption reports only its fingerprint. Without an organization key, the organization's data is wrapped with the deployment's key (encryption.provider in provisa.yaml). A deployment that configures no encryption provider stores encrypted columns and audit query text unencrypted; secrets are never stored in the clear (see Secrets).
The organization's folder holds its uploaded and landed files, its engine's spill files and its temporary files. An organization admin turns its encryption on under Admin → Org Secrets → File folder encryption, or with PUT /admin/org-files ({"encrypted": true, "key_reference": "${secret:NAME}"}). POST /admin/org-files/generate-key answers a new 256-bit key once, to be stored in the vault. The key stays in the organization's own vault (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault or HashiCorp Vault): Provisa stores only the reference, and gocryptfs keeps the folder's files encrypted on disk under the key. This needs FUSE, so a Linux host or container with /dev/fuse; on macOS the setting is refused.
On a self-hosted deployment, which typically runs one organization on its own engines, turning both on encrypts that organization's secrets, files, spill files and temporary files under its keys:
- The DuckDB engine an organization runs spills to the organization's folder.
- Rows a row filter fetches and uploaded code are staged in the organization's folder.
- Trino's fault-tolerant execution encrypts spooled exchange data with a key generated for each query and discarded when it ends (
fault-tolerant-execution.exchange-encryption-enabled=true). Provisa does not turn on Trino's legacy spill to disk. - Trino keeps the organization's catalogs, with their credentials, in memory and writes none to disk (
catalog.store=memory); a Kafka source's SASL client settings are in a file only Trino reads, by its user or its group, in memory in the container stack (see The federation engine). - A PostgreSQL federation engine writes its temporary files where that PostgreSQL server keeps them, under the server's own storage encryption, not the organization's folder.
Shared and dedicated hosted platforms¶
The shared hosted platform isolates organizations logically. One deployment serves them all, and each organization's data stays its own through governance, its own schemas, catalogs and resource groups on the engine, its own keys, per-query keys for spooled engine data, and the refusal of its sources at the platform's private addresses. It is recommended for experimentation and small businesses. A dedicated hosted platform is physically separated: its own machines, network and stores, serving one customer. For highly sensitive or regulated workloads, self-host Provisa inside your own boundary, where your own operators hold every key and every host. The hosted platforms do not carry a SOC 2 report. If you require SOC 2, we recommend self-hosting.
Python in the browser¶
Explore's Python and Notebook tabs run Python in the visitor's browser. Every read it makes goes to the server as the signed-in user, with their acting role, organization and environment, and is governed exactly as the same statement from any other client.
High-Security Mode¶
security.mode: high in provisa.yaml asserts one guarantee: the Provisa backend never handles plaintext data. Every column that matters is encrypted at the source, and only a client holding the decryption key can read it. That guarantee has consequences a deployment must plan for.
What the mode does:
- Data endpoints require proof of client-side decryption. Everything under
/data/returns 403 unless the caller presents theX-Provisa-KMS-Keyheader, the marker of a JDBC or Python client configured to decrypt locally. A browser or a plaintext REST consumer carries no such key and is refused. The gate is a default-deny over the whole tree: a route added tomorrow is gated on the day it ships, and an exemption has to be argued for. - Schema-metadata endpoints stay open.
/data/sdl,/data/introspection,/data/schema-version,/data/domains,/data/protoand/data/compilereturn no row data, and a client has to read the schema (including which fields are@encrypted) before it can connect at all. - gRPC and Arrow Flight keep serving, under the same proof. They are the transports encrypting clients actually use; closing them would leave a high-security deployment with no wire protocol. A data call on either must carry the same KMS key as call metadata.
- pgwire, Bolt and MCP do not start. None of the three has a per-connection handshake that can carry a decryption context: a pgwire row set and a Cypher result are plaintext on the wire, and an MCP tool call hands its results to a model as text. A configured port for any of them is refused at startup rather than served.
- The relationship guard cannot be bypassed.
ignore_relationshipsandrelationship_guard: falseare both ignored; see Relationship governance.
Verifying a deployment is in the mode: the startup log names it, a /data/sql request without a KMS key answers 403 with a message naming high-security mode, and the pgwire, Bolt and MCP ports are not listening.
ABAC Approval Hook¶
An optional external policy hook that fires before query execution. When configured, Provisa calls out to your policy engine with the user identity, roles, tables, columns, and operation. The response determines whether the query proceeds. A command registered with Requires approval is put to the same hook before each call; see Command calls.
Scoping¶
The hook fires only when a query touches a scoped table or source, so every other query runs with no added cost.
| Config | Effect |
|---|---|
auth.approval_hook.scope: all |
Every query triggers the hook |
sources[].approval_hook: true |
All tables on that source trigger the hook |
tables[].approval_hook: true |
That table triggers the hook |
Protocols¶
Three transports are supported:
| Type | Use case | Config field |
|---|---|---|
webhook |
Any HTTP-capable policy service (OPA, custom) | url |
unix_socket |
OPA or policy sidecar on same machine | socket_path + url |
grpc |
High-throughput co-located policy service | url (host:port) |
The gRPC transport uses the provisa.auth.ApprovalService contract defined in provisa/auth/approval.proto. Implement this service in your policy engine:
service ApprovalService {
rpc Evaluate (ApprovalRequest) returns (ApprovalResponse);
}
message ApprovalRequest {
string user = 1;
repeated string roles = 2;
repeated string tables = 3;
repeated string columns = 4;
string operation = 5;
map<string, string> session_vars = 6;
string command = 7; // a command call's name; empty for a query
string arguments_json = 8; // a command call's arguments as a JSON object
}
message ApprovalResponse {
bool approved = 1;
string reason = 2;
}
The gRPC channel is persistent: one channel per Provisa instance, reused across all calls to that hook endpoint.
Request / Response¶
All three transports carry the same payload:
| Field | Type | Description |
|---|---|---|
user |
string | Authenticated user identity |
roles |
string[] | User's Provisa roles |
tables |
string[] | Table IDs referenced in the query |
columns |
string[] | Columns selected in the query |
operation |
string | "query" or "mutation"; "command" for a command call |
command |
string | The command's name for a command call; empty for a query |
arguments |
object | The arguments a command is called with; empty for a query |
The webhook and Unix socket transports exchange JSON, with command and arguments as keys. On gRPC the arguments travel as the JSON text arguments_json. Response must include approved (bool) and optionally reason (string).
Command calls¶
A command with requires_approval set (the Requires approval switch on the command form) goes to the hook before every call, on every surface. It runs only when the hook approves. The request carries operation: "command", the command's name, and its arguments; tables and columns are empty.
{
"user": "analyst",
"roles": ["analyst"],
"tables": [],
"columns": [],
"operation": "command",
"command": "create_issue",
"arguments": {"input": {"title": "Crash on save"}}
}
Two refusals, both 403:
functions.approval_unavailable: no hook is configured. Unlike a query, a command that needs approval is never let through without one.functions.approval_denied: the hook answeredapproved: false. The reason is in the message.
Timeout and Fallback¶
auth:
approval_hook:
type: grpc # webhook | grpc | unix_socket
url: "localhost:50051"
timeout_ms: 500 # default 5000
fallback: deny # allow | deny — applied on timeout or error
scope: "" # "" = use per-table/per-source flags; "all" = every query
On timeout or transport error, the fallback policy applies. A circuit breaker (default: open after 5 consecutive failures, half-open after 30s) prevents cascading failures from a slow hook endpoint.
Configuration Example¶
auth:
approval_hook:
type: webhook
url: "http://opa.internal:8181/v1/data/provisa/allow"
timeout_ms: 300
fallback: deny
sources:
- id: analytics_pg
approval_hook: true # all tables on this source require hook approval
tables:
- id: salary_data
approval_hook: true # this table always requires hook approval
Secrets¶
Credentials use ${env:VAR_NAME} syntax, resolved at runtime. Passwords are never stored in the config DB.
For the full secrets service (vaults, reference syntax, and providers), see Secrets.