You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
- Remove `action` field from `ColumnAccessDef`; intent now derived solely from policy effect (permit=allowlist, deny=denylist)
- Fix 14 unit tests in `hooks/policy.rs` that incorrectly placed column_access deny obligations inside permit policies
- Add `tc_rf_01_neq_operator_quoted_column` integration test covering `!=` operator and double-quoted column identifiers
- Strip all stale `"action": "allow"/"deny"` fields from integration tests in `policy_enforcement.rs` and `protocol.rs`
- Fix `scripts/demo_ecommerce/policies.yaml`: change hide-credit-card and hide-product-financials to deny-effect policies
- Update `docs/permission-system.md` and `docs/roadmap.md` to remove action field references throughout
- Add security vector #20 to `docs/permission-security-tests.md`
- Update `proxy/CLAUDE.md`: document integration test infrastructure, TDD bug fix protocol, and comprehensive test coverage requirement
Copy file name to clipboardExpand all lines: docs/permission-security-tests.md
+19Lines changed: 19 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -238,3 +238,22 @@ WITH data AS (SELECT * FROM orders) SELECT * FROM data
238
238
**Defense**: `matches_pattern()` in `policy_match.rs` supports prefix glob: if the pattern ends with `*`, it uses `starts_with(prefix)` matching. `matches_schema_table()` delegates to `matches_pattern()` for both schema and table fields. The same function is used by both `PolicyHook` (query-time) and `compute_user_visibility()` (connect-time), ensuring consistent semantics.
239
239
240
240
**Test**: 14 unit tests in `proxy/src/policy_match.rs` cover exact match, `*` wildcard, prefix glob on table, prefix glob on schema, combined globs, alias resolution, and non-matching cases (`orders_raw` does not match `raw_*`).
241
+
242
+
---
243
+
244
+
### 20. `column_access` obligation `action` field caused incorrect visibility in `policy_required` mode
245
+
246
+
**Vector**: Admin creates a `permit`-effect policy intended to grant access to all columns (`columns: ["*"]`) but the stored obligation JSON has `"action": "deny"` (from a prior schema where the action was set per-obligation). In `policy_required` mode the table is completely inaccessible — the user sees a "table not found" error instead of data.
247
+
248
+
**Bug**: The `column_access` obligation definition previously contained an `action` field (`"allow"` or `"deny"`). `compute_user_visibility()` and `ObligationEffects::collect()` both checked `col_def.action == "allow"` to decide whether a permit policy's `column_access` obligation grants table visibility. When `action` was `"deny"` inside a permit policy, the obligation was silently treated as a denylist and never added to `visible_tables`. In `policy_required` mode, `visible_tables` remained empty → the table was filtered out of the user's `SessionContext` → DataFusion returned "table not found".
249
+
250
+
**Defense**: The `action` field was removed from `column_access` obligations entirely. Intent is now determined solely by the enclosing policy's `effect`:
251
+
-`permit` policy + `column_access` → always an allowlist (adds to `visible_tables` and specifies visible columns)
252
+
-`deny` policy + `column_access` → always a denylist (strips columns from results, does not grant access)
253
+
254
+
The `ColumnAccessDef` struct no longer has an `action` field. The API validation layer (`dto.rs`) no longer requires or accepts `action` in `column_access` definitions. Existing stored obligations with an `action` field are silently ignored (serde unknown-field tolerance).
255
+
256
+
**Test**:
257
+
- Unit: `engine::tests::test_permit_column_access_wildcard_grants_full_visibility_policy_required` — permit policy with `column_access ["*"]` in a `policy_required` aliased datasource → table is visible, `visible_tables` non-empty.
258
+
- Unit: `hooks::policy::tests::test_column_access_deny_no_table_permit` — deny policy with `column_access` in `policy_required` mode → `lit(false)` (deny policy alone does not grant table access).
259
+
- Unit: `admin::policy_handlers::tests::create_policy_column_access_missing_columns_422` — `column_access` definition without `columns` field → `422`.
Copy file name to clipboardExpand all lines: docs/permission-system.md
+33-31Lines changed: 33 additions & 31 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,16 +8,16 @@ Permissions are defined as **policies**. A policy is a named, reusable unit that
8
8
9
9
When a user runs a query:
10
10
1. The proxy loads all **enabled** policies assigned to the datasource for that user.
11
-
2.`deny` policies are evaluated first. A `row_filter` match on a deny policy rejects the query with an error. `column_access deny` obligations on deny policies are collected alongside those from permit policies (step 3).
12
-
3.`permit` policies are applied — their obligations rewrite the query in-flight (row filters, column masks, column access controls). Column denies from both permit and deny policies are combined.
11
+
2.`deny` policies are evaluated first. A `row_filter` match on a deny policy rejects the query with an error. `column_access` obligations on deny policies strip specific columns from results.
12
+
3.`permit` policies are applied — their obligations rewrite the query in-flight (row filters, column masks, column access controls). Column denies from deny policies are applied on top.
13
13
4. The rewritten query executes against the upstream database.
14
14
15
15
## Policy effects
16
16
17
-
-**permit** — applies obligations to the query (row filtering, masking, etc.)
18
-
-**deny** — primarily used to block access with an error. Can also carry `column_access deny` obligations to strip specific columns from results.
17
+
-**permit** — applies obligations to the query (row filtering, masking, column allowlisting, etc.)
18
+
-**deny** — primarily used to block access with an error. Can also carry `column_access` obligations to strip specific columns from results.
19
19
20
-
Deny policies are evaluated before permit policies. If a deny policy has a matching `row_filter` obligation, the query is rejected immediately with an error. `column_access deny` obligations on deny policies strip columns from results just like they do on permit policies — they do not cause an error.
20
+
Deny policies are evaluated before permit policies. If a deny policy has a matching `row_filter` obligation, the query is rejected immediately with an error. `column_access` obligations on deny policies strip columns from results — they do not cause an error.
21
21
22
22
> **`is_enabled` flag**: only enabled policies are enforced. Disabling (or enabling) a policy removes (or adds) all its effects immediately — both for query-time enforcement and for schema visibility — without requiring a reconnect.
23
23
>
@@ -57,23 +57,27 @@ Replaces a column's value with a masked expression in query results.
57
57
58
58
When multiple mask policies target the same column, the one with the **lowest priority number** (highest precedence) wins.
59
59
60
-
### column_access (deny)
60
+
### column_access
61
61
62
-
Removes the specified columns from query results entirely.
62
+
Controls which columns a user can see. The **policy effect** determines the obligation's intent:
63
+
64
+
-**In a `permit` policy** → acts as an **allowlist**: only the listed columns are visible; all others are hidden from schema metadata and query results. This is also the only obligation that makes a table accessible in `policy_required` mode.
65
+
-**In a `deny` policy** → acts as a **denylist**: the listed columns are removed from schema metadata and query results.
63
66
64
67
```json
65
68
{
66
69
"schema": "public",
67
70
"table": "customers",
68
-
"columns": ["ssn", "credit_card"],
69
-
"action": "deny"
71
+
"columns": ["ssn", "credit_card"]
70
72
}
71
73
```
72
74
73
-
Denied columns are unioned across all matching **enabled**policies, regardless of effect — if any enabled policy (permit or deny) denies a column, it is removed from the result. The column is also hidden from schema metadata (`information_schema.columns`) on the user's connection.
75
+
Denied columns from `deny`policies are unioned — if any enabled deny policy removes a column, it is absent from results regardless of permit policies.
74
76
75
77
If the query selects **only** denied columns (e.g. `SELECT ssn FROM customers`), the proxy returns SQLSTATE `42501` (insufficient privilege) with a message identifying the restricted columns rather than returning empty rows.
76
78
79
+
Glob patterns are supported in the `columns` field — see [Column glob patterns](#column-glob-patterns-columns-field) below.
80
+
77
81
### object_access (deny)
78
82
79
83
Hides entire schemas or individual tables from a user's virtual catalog — they become invisible in `information_schema.schemata`/`information_schema.tables`, SQL client sidebars, and query execution.
> **`column_mask` on deny policies is invalid.** The API returns `422 Unprocessable Entity` if you attempt to create or update a `deny`-effect policy with a `column_mask` obligation. The UI hides `column_mask` from the available obligation types when `deny` is selected. Only `column_access` and `object_access` obligations are supported on deny policies.
105
109
106
-
> **Obligation definition validation.** The API validates obligation definitions at create/update time and returns `422 Unprocessable Entity` for malformed payloads: unknown `obligation_type` values, missing required fields (`schema`, `table`, `filter_expression`, `column`, `mask_expression`, `columns`, `action`), wrong field types, or invalid `action` values (`column_access` accepts`"allow"` or `"deny"`; `object_access` accepts `"deny"` only). Extra fields in the definition are permitted for forward compatibility.
110
+
> **Obligation definition validation.** The API validates obligation definitions at create/update time and returns `422 Unprocessable Entity` for malformed payloads: unknown `obligation_type` values, missing required fields (`schema`, `table`, `filter_expression`, `column`, `mask_expression`, `columns`, `action`), wrong field types, or invalid `action` values (`object_access` requires`"deny"`). The `column_access` obligation has no `action` field — intent is determined by the enclosing policy's `effect`. Extra fields in the definition are permitted for forward compatibility.
107
111
108
112
## Template variables
109
113
@@ -173,47 +177,46 @@ Column visibility follows a **zero-trust** model in `policy_required` mode: a ta
|`row_filter`|permit |**No**| No | Yes (filters rows) |
183
+
|`column_mask`|permit |**No**| No | Yes (transforms value) |
184
+
|`column_access`|**permit**|**Yes**| Yes (named columns only) | No |
185
+
|`column_access`|**deny**|**No** — does not unblock a table | Removes named columns | No |
186
+
|`object_access`|deny | Removes table/schema | Removes all | No |
183
187
184
-
### `column_access "allow"` — the access grant obligation
188
+
### `column_access` in a permit policy — the access grant obligation
185
189
186
-
`column_access "allow"` is the **only** obligation that makes a table visible in `policy_required` mode. It also specifies which columns the user can see:
190
+
`column_access` in a **permit** policy is the **only** obligation that makes a table visible in `policy_required` mode. It also specifies which columns the user can see:
187
191
188
192
```json
189
193
{
190
194
"schema": "public",
191
195
"table": "customers",
192
-
"columns": ["id", "name", "email"],
193
-
"action": "allow"
196
+
"columns": ["id", "name", "email"]
194
197
}
195
198
```
196
199
197
-
With only this obligation, the user sees the `customers` table with exactly three columns. Any column not in the `columns` list is invisible in both schema metadata and query results.
200
+
With only this obligation (on a permit policy), the user sees the `customers` table with exactly three columns. Any column not in the `columns` list is invisible in both schema metadata and query results.
198
201
199
202
### Composing access with row filters
200
203
201
-
`column_access "allow"`and `row_filter` in the same policy stack correctly — the allow obligation grants access and column visibility, while the row filter restricts which rows are returned:
204
+
`column_access` (permit) and `row_filter` in the same policy stack correctly — the `column_access` obligation grants access and column visibility, while the row filter restricts which rows are returned:
Result: only `id` and `name` columns, filtered to the user's tenant rows.
211
214
212
-
### `column_access "deny"` does not grant access
215
+
### `column_access` in a deny policy does not grant access
213
216
214
-
In `policy_required` mode, a policy that **only**has `column_access "deny"` obligations does **not** unblock the table. The table remains blocked by `lit(false)`. Use `column_access "allow"`first to grant access, then optionally layer `column_access "deny"`to remove specific columns.
217
+
In `policy_required` mode, a **deny**policy with `column_access` obligations does **not** unblock the table. The table remains blocked by `lit(false)`. Use `column_access` on a **permit** policy first to grant access, then layer a `column_access`deny policy to strip specific columns.
215
218
216
-
In `open` mode, `column_access "deny"`removes the specified columns from results (same behaviour as before).
219
+
In `open` mode, `column_access` on a deny policy removes the specified columns from results.
217
220
218
221
### JOIN column scoping
219
222
@@ -443,16 +446,15 @@ Partially mask SSNs for support staff:
443
446
```
444
447
445
448
### Column access control (DS-10)
446
-
Remove sensitive columns entirely:
449
+
Remove sensitive columns entirely using a deny policy:
Copy file name to clipboardExpand all lines: proxy/CLAUDE.md
+33-2Lines changed: 33 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -75,9 +75,40 @@ Policy CRUD handlers also call `state.proxy_handler.rebuild_contexts_for_datasou
75
75
76
76
**Audit logging**: after each query, `PolicyHook` spawns a `tokio::spawn` task to insert a `query_audit_log` row asynchronously. The row captures `original_query`, `rewritten_query`, `policies_applied` (JSON with name+version snapshot), `client_ip`, and `client_info` (application_name from pgwire startup params).
77
77
78
+
## Testing
79
+
80
+
Data security and robustness are core product requirements. Every feature must ship with comprehensive unit and integration tests covering happy paths, edge cases, and security boundaries. Aim for best-in-class coverage — not just "it works", but "it cannot be bypassed".
81
+
82
+
### Unit tests (`src/**`)
83
+
Inline `#[cfg(test)]` modules in each source file. No external dependencies — run with `cargo test --lib`.
84
+
85
+
### Integration tests (`tests/`)
86
+
Two test binaries: `policy_enforcement.rs` (security/policy scenarios) and `protocol.rs` (pgwire protocol). Shared infrastructure in `tests/support/mod.rs`.
87
+
88
+
Run with `cargo test --test policy_enforcement` or `cargo test --test protocol`. Require Docker — skipped gracefully via `require_postgres!()` if unavailable.
89
+
90
+
**`ProxyTestServer::start()`** spins up a complete isolated stack per test:
91
+
- One shared `testcontainers` Postgres container per test binary (`OnceLock`) — not per-test.
92
+
- Fresh in-memory SQLite admin DB per test with all migrations applied.
93
+
- Real `ProxyHandler` on a random TCP port with a live pgwire accept loop.
94
+
-`axum_test::TestServer` wrapping the admin API for HTTP calls.
95
+
96
+
**Conventions:**
97
+
- Each test uses a unique upstream schema name (e.g. `"t1_rowfilt"`, `"tc_rf01"`) to avoid collisions during parallel execution.
98
+
- TC-prefixed tests map to security vector numbers in `docs/permission-security-tests.md`.
99
+
- Template vars in `filter_expression` must not be quoted: `tenant = {user.tenant}` ✓, `tenant = '{user.tenant}'` ✗.
100
+
101
+
### Documentation requirements (non-optional)
102
+
After completing any feature or adding tests, always update:
103
+
-**`docs/permission-security-tests.md`** — add a new vector entry for any new attack surface or bypass that was tested (Vector → Bug/Defense → Test format).
104
+
-**`docs/permission-system.md`** — keep the conceptual model, obligation descriptions, and examples in sync with the current implementation.
105
+
78
106
## Bug Fix Protocol
79
-
- Every bug fix MUST include a regression test (unit or integration) that fails before the fix and passes after.
80
-
- Security-related bugs (policy bypass, access control, injection) MUST also be documented in `docs/permission-security-tests.md` following the existing Vector → Bug → Defense → Test format.
107
+
Use TDD: write the failing test(s) first to reproduce the bug, then fix the code until they pass. Never fix first and test after.
108
+
109
+
1. Write unit and integration tests that reproduce the bug and fail on the current code. Cover all relevant edge cases — add as many tests as needed, not just one of each.
110
+
2. Fix the code until the test passes.
111
+
3. Security-related bugs (policy bypass, access control, injection) MUST also be documented in `docs/permission-security-tests.md` following the existing Vector → Bug → Defense → Test format.
81
112
82
113
## Known Issues
83
114
-**regclass / regproc not supported** — `datafusion-table-providers` drops these columns. Catalog stores `arrow_type = NULL`; `build_arrow_schema` skips them.
0 commit comments