Skip to content

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)

See Row-Level Security (RLS).

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:

  1. Role flagrelationship_guard: false on the role definition (default: true). [tool-verified: provisa/core/models.py:349]
  2. 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 sees alice@example.com 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_access domains ("*" = all) (REQ-039)
  • Column visibility: Columns not in visible_to for 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 revocationPOST /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 the X-Provisa-KMS-Key header — 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/proto and /data/compile return 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_relationships and relationship_guard: false are 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)