Security Model¶
Provisa enforces a multi-layered security model across every query language (GraphQL, SQL, Cypher) and every transport (REST, gRPC, Arrow Flight, JDBC, WebSocket). (REQ-001, REQ-266) Governance is applied uniformly — there is no query path that bypasses it. (REQ-002, REQ-266)
The layers apply in order. A request must clear each layer before the next is evaluated.
Layered Model¶
Layer 0 — Introspection filtering¶
The schema and catalog presented to a role contain only the tables in its domain_access list and the columns that pass per-column visible_to rules. (REQ-039) Objects outside a role's access are invisible at discovery time — they cannot be queried, autocompleted, or inferred to exist. (REQ-039) This applies to the GraphQL schema, SQL catalog, and the query editor's schema browser. (REQ-039, REQ-363)
See Schema Visibility.
Layer 1 — Public access¶
Tables in domains with no domain_access restriction are visible to all authenticated identities with no additional configuration. Zero friction for genuinely public data.
Layer 2 — Domain access¶
Each role carries a domain_access list of domain IDs. A query that touches a table outside those domains is rejected before execution. (REQ-038, REQ-039) This is the coarse ownership boundary — an HR role cannot reach finance tables regardless of how the SQL is written. (REQ-002)
See Rights Model.
Layer 3 — Row-level security¶
After domain access is confirmed, per-table, per-role WHERE predicates are injected into every SELECT at execution time. (REQ-041, REQ-263) The predicates evaluate against raw data. A regional manager querying a shared orders table sees only their region's rows even on a SELECT *. (REQ-264)
Layer 4 — Column visibility and masking¶
Columns with a visible_to list that excludes the requesting role are stripped from query output. (REQ-040, REQ-263) Columns with a masking rule have their values replaced — regex redaction, constant replacement, or truncation — before results leave the server. (REQ-263) Masking applies in all query languages and output formats. (REQ-263)
See Column Permission Model and Column-Level Masking.
Layer 5 — Predicate guard¶
Masked columns are rejected from WHERE and HAVING clauses. (REQ-263) Without this, a caller could infer the unmasked value by binary-searching it in a filter even though the output is masked. Rejection is enforced at query parse time, before execution. (REQ-531)
Relationship governance (V002)¶
JOIN conditions in SQL must match a registered, approved relationship between tables. (REQ-001) Unapproved joins are rejected. Each relationship carries a human-readable reason and description — guidance for both users and autonomous agents about why a traversal path exists. This is governance policy, not a hard security boundary: Layers 2–5 hold regardless of join structure, so a deliberate circumvention does not expose data the role could not reach through two separate queries. Circumvention attempts are logged and auditable.
Bypass mechanisms — V002 can be bypassed two ways. The first is a capability: a role holding ignore_relationships joins across relations the catalog does not cover. Among the seeded system roles only modeler holds it — the discovery role whose job is to determine the model rather than enforce it. (REQ-1297) analyst does not. [tool-verified: provisa/core/db.py:84]
The second is a two-condition opt-out, where both must be true:
- Role flag —
relationship_guard: falseon the role definition (default:true). [tool-verified:provisa/core/models.py:349] - Per-query opt-out — the SQL contains the comment
--relationship-guard=false. [tool-verified:provisa/compiler/params.py:80]
The role flag alone does not bypass V002; the comment alone does not bypass V002.
High-security mode pins the guard. Under security.mode: high neither bypass applies: ignore_relationships is ignored, relationship_guard: false is ignored, and every join must exist in the approved relationship catalog. (REQ-693) This is deliberate redundancy — a production role that was granted the capability by mistake still cannot break out of the model. [tool-verified: provisa/pgwire/_pipeline.py:377]
GraphQL path — V002 is unconditionally skipped for GraphQL queries. SDL-defined relationships are pre-approved by design; the check is redundant and is not applied. [tool-verified: provisa/api/data/endpoint.py:468]
SQL and Cypher paths — V002 is active by default. Both endpoint_dev.py and cypher_router.py apply the two-condition check before calling validate_sql. [tool-verified: provisa/api/data/endpoint_dev.py:127, provisa/api/rest/cypher_router.py:260]
pgwire path — same two-condition check as SQL. The --relationship-guard=false comment is stripped from the query before execution; it does not reach the database. [tool-verified: provisa/pgwire/_pipeline.py:60]
These layers compose. A role with domain access, RLS, and masked columns has all five constraints active simultaneously. Adding a new data source, column, or relationship does not require updating every rule — each layer is configured independently and applies automatically to any query that touches governed objects.
Rights Model¶
Independently assigned capabilities with optional role hierarchy via parent_role_id. admin grants all. (REQ-042)
| Capability | Description |
|---|---|
source_registration |
Register data sources |
table_registration |
Register tables, columns |
create_relationship |
Define FK relationships |
access_config |
Configure RLS, masking |
query_development |
Execute queries |
write |
Invoke registered mutations (coarse gate; see Mutation Authorization) |
full_results |
Bypass sampling limits |
ignore_relationships |
Bypass relationship governance (V002). Held by modeler only among the system roles, and ignored entirely in high-security mode |
admin |
Superuser — grants all |
Role Inheritance¶
Roles can inherit capabilities and domain access from a parent role via parent_role_id. (REQ-215) The hierarchy is flattened at startup — child roles merge their parent's capabilities and domain access with their own. (REQ-215)
roles:
- id: basic_user
capabilities: [query_development]
domain_access: [public]
- id: analyst
capabilities: [full_results]
domain_access: [sales, analytics]
parent_role_id: basic_user # inherits query_development + public domain
Column Permission Model¶
Each column has a four-field permission model controlling read, write, and masking access per role. (REQ-042, REQ-249)
Three-Tier Visibility¶
| Tier | Condition | Result |
|---|---|---|
| Hidden | Role not in visible_to |
Column absent from GraphQL SDL |
| Masked | Role in visible_to, has masking rule, role not in unmasked_to |
Column visible but data masked in SQL |
| Unmasked | Role in visible_to AND role in unmasked_to (or no masking rule) |
Full read access |
Write Permissions¶
| Field | Empty means | Purpose |
|---|---|---|
visible_to |
All roles can read | Controls who sees the column (masked or unmasked) |
unmasked_to |
No role sees unmasked | Controls who bypasses masking |
writable_by |
No role can write | Controls who can mutate (INSERT/UPDATE) |
Write permission is enforced in the mutation pipeline. A role not in writable_by receives a 403 error when attempting to write to a restricted column. (REQ-033, REQ-034)
Example¶
columns:
- name: email
visible_to: [admin, analyst, viewer]
writable_by: [admin]
unmasked_to: [admin]
mask_type: regex
mask_pattern: "(.).*@"
mask_replace: "$1***@"
- name: salary
visible_to: [admin, hr]
writable_by: [hr]
unmasked_to: [admin, hr]
mask_type: constant
mask_value: "0"
- name: created_at
visible_to: [] # all can read
writable_by: [] # nobody can write (auto-set)
In this example:
email: admin seesalice@example.comand can edit; analyst/viewer seea***@example.comsalary: admin and hr see the real value; hr can edit; all other roles don't see the column at allcreated_at: everyone can read, nobody can write
Mutation Authorization¶
Registered mutations (remote GraphQL, OpenAPI, gRPC, Hasura) are gated by two independent checks. (REQ-867, REQ-868) A role may invoke a mutation only if it holds the global write capability AND appears in that mutation's writable_by list. (REQ-868) An empty writable_by is default-deny — no role can invoke it. (REQ-867)
Mutations are classified as writes by contract, not by caller declaration. (REQ-869) A SELECT that references a mutation-kind function is promoted to a write and subject to the same two-gate check, so a caller cannot invoke a mutation by disguising it as a read. (REQ-869) Reclassifying a mutation to read-safe requires the access_config capability and is recorded as a governance decision; there is no per-request opt-out. (REQ-870)
Schema Visibility¶
Per-role GraphQL schemas hide unauthorized content: (REQ-039)
- Domain access: Role sees tables only in its
domain_accessdomains ("*"= all) (REQ-039) - Column visibility: Columns not in
visible_tofor a role are omitted from the SDL (REQ-039) - Unauthorized tables/columns do not appear in the schema (REQ-039)
Row-Level Security (RLS)¶
Per-table, per-role SQL WHERE clause injection. Applied after compilation, before execution. (REQ-041, REQ-263)
rls_rules:
- table_id: orders
role_id: analyst
filter: "region = current_setting('provisa.user_region')"
The filter is ANDed into the query's WHERE clause. Works for both queries and mutations (UPDATE/DELETE). (REQ-035, REQ-041)
Column-Level Masking¶
Masking is defined once per column — it is a property of the column, not the role. The unmasked_to field controls which roles bypass it. (REQ-249)
| Mask Type | Supported Types | SQL Expression |
|---|---|---|
regex |
String (varchar, char, text) | REGEXP_REPLACE(col, pattern, replace) |
constant |
Any | Literal value (NULL, 0, custom) |
truncate |
Date/Timestamp | DATE_TRUNC(precision, col) |
Masking is pushed into the SQL SELECT projection — the database returns masked data. (REQ-263) Unmasked data never crosses the wire for masked roles. (REQ-263) Masked columns are also blocked from WHERE and HAVING clauses (Layer 5 predicate guard) to prevent inference of the unmasked value through filtering. (REQ-263, REQ-531)
Sampling¶
All roles see sampled results (default: 100 rows) unless they have full_results capability. (REQ-554) Controlled via PROVISA_SAMPLE_SIZE env var. (REQ-554)
Audit Logging¶
Every query that touches a domain asset is recorded in the append-only query_audit_log. (REQ-596, REQ-613) Each row captures tenant_id, user_id, role_id, a SHA-256 hash of the query text, table_ids, source, status_code, duration_ms, and logged_at. (REQ-596) The query text is never stored verbatim — only its hash. (REQ-596)
The log is append-only at the database level: PostgreSQL rules block DELETE and UPDATE. (REQ-596, REQ-613) Two indexes — (tenant_id, logged_at) and (user_id, logged_at) — support tenant-scoped and per-user time-range compliance queries. (REQ-596, REQ-613)
When encryption is enabled, the query text hash column is stored encrypted and decrypted only on authorized admin reads. (REQ-689)
Rate Limiting¶
Per-role rate limits are configured in provisa.yaml: max requests per second, max concurrent SSE subscriptions, and max concurrent Arrow Flight streams. (REQ-369) Limits are enforced at the API layer before compilation or execution; requests over the limit are rejected with HTTP 429 and a Retry-After header. (REQ-369)
The NL query service (POST /query/nl) has an independent limit via nl.rate_limit (requests per minute per role). Requests over the limit are rejected before any LLM call is made. (REQ-370)
Rate limit state lives in Redis (cache.redis_url) as a sliding-window counter — no per-instance state — so limits hold across all horizontal Provisa instances. (REQ-371)
Authentication¶
Pluggable auth providers: (REQ-120)
| Provider | Token Type | Use Case |
|---|---|---|
none |
X-Provisa-Role header | Development |
basic |
bcrypt local accounts + JWT | Self-contained deployments |
firebase |
Firebase ID token | Production |
keycloak |
Keycloak JWT | Enterprise |
oauth |
OIDC JWT | PingFed, Okta, Azure AD, Auth0 |
simple |
bcrypt + JWT | Testing |
Role mapping: identity claims → Provisa role via configurable rules. (REQ-120) 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. (REQ-551)
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. (REQ-125)
Surfaces and credentials¶
Every surface authenticates through the same provider contract, so a credential that works on one works on all of them wherever the protocol can carry it. (REQ-124, REQ-1263) This table is the single reference; the per-surface docs do not restate 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 |
same | yes |
| gRPC | — | authorization metadata |
authorization metadata |
yes |
| MCP | — | Authorization: Bearer |
Authorization: Bearer |
via terminating proxy |
Where a cell reads — the protocol carries no username field to pair a password with; the token forms cover it. pgwire is the mirror case: the startup packet has one secret field and no scheme, so what the secret is picks the method — a PAT is recognized by its prefix, the secret is read as a bearer token when the configured provider is a token provider, and anything else is a password. The choice is made once — a credential the selected validator refuses is not retried against another.
The matrix is enforced by tests/unit/test_auth_surface_conformance.py, which drives each surface's real validation entry point and fails when a new surface is added without a row.
Personal access tokens¶
A PAT is a long-lived bearer secret a user mints for a client that cannot complete an interactive login — a script, a BI tool, a driver. (REQ-1263) It carries its own org and role, and every surface resolves it through the same validator, so no surface needs to know what a PAT is.
The wire form is provisa_pat_ followed by 43 url-safe base64 characters. The prefix is what routes a presented secret to the token store instead of the identity provider, and it makes a leaked token greppable in logs and repositories.
- Storage — only the SHA-256 of the secret is kept. The secret itself is shown exactly once, at creation, and cannot be recovered. The listing carries the display prefix and the lifecycle timestamps, never a working credential.
- Issuance and revocation —
POST /auth/tokens,GET /auth/tokens,DELETE /auth/tokens/{token_hash}, plus the self-service section on the user's own profile in the admin UI. Minting and revoking a credential is the token holder's act. - Attribution — a validated PAT resolves to its owner's account: user id, email and display name. An audit row or usage report written under a PAT therefore names the person, not the credential. Which of that person's tokens acted is carried separately, in
raw_claims["token_name"]. - Expiry — a token may carry an expiry; an expired token is refused at validation. Deleting 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. (REQ-1394) 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. (REQ-1228) 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.
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. (REQ-1393)
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 examined 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.
Addressing an org on a wire protocol¶
Under multitenancy an org is addressed by hostname: acme.provisa.dev is org acme. Over HTTP that name arrives in the Host header. A pgwire or Bolt client sends no such header, but it does send the hostname it dialed in the TLS ClientHello, and Provisa reads the org from there. (REQ-1234) Nothing about the client changes — connecting to acme.provisa.dev is all it takes.
The hostname is a request, not a grant. It reaches the same resolver the Host header does, which refuses any org the authenticated principal is neither a member of nor holds the cross-org right for. Dialing a hostname you have no membership in reaches no data. A client that connected by IP address sends no hostname and resolves its org from the principal alone, which is every connection on a single-org deployment.
gRPC, Arrow Flight and MCP hand their certificates to libraries that expose no hostname callback; those transports name an org with the x-provisa-org metadata header instead.
High-Security Mode¶
security.mode: high in provisa.yaml asserts one guarantee: the Provisa backend never handles plaintext data. (REQ-693) 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 REQ-693, and the pgwire, Bolt and MCP ports are not listening.
ABAC Approval Hook¶
An optional external policy hook that fires before query execution. (REQ-203) 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. (REQ-203)
Scoping¶
The hook only fires when the query touches a scoped table or source — zero overhead for everything else. (REQ-204)
| 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: (REQ-246)
| 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: (REQ-246)
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;
}
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. (REQ-555)
Request / Response¶
All three transports carry the same payload: (REQ-246)
| 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" |
The webhook and Unix socket transports exchange JSON. Response must include approved (bool) and optionally reason (string). (REQ-246)
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. (REQ-247) A circuit breaker (default: open after 5 consecutive failures, half-open after 30s) prevents cascading failures from a slow hook endpoint. (REQ-556)
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. (REQ-557) Passwords are never stored in the config DB. (REQ-557)