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 only when two independent conditions are both true:

1. Role flag — `relationship_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`]

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 `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
`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)