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 only when two independent conditions are both 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]
Both must be present. The role flag alone does not bypass V002; the comment alone does not bypass V002.
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) |
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 sees [email protected] and can edit; analyst/viewer see a***@example.com
- salary: admin and hr see the real value; hr can edit; all other roles don't see the column at all
- created_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 |
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)
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)