Skip to content

Commit 60558a6

Browse files
Remove column_access action field, fix 14 hook tests, and strengthen test/doc standards
- 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
1 parent 86f4cb9 commit 60558a6

12 files changed

Lines changed: 433 additions & 167 deletions

File tree

docs/permission-security-tests.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,22 @@ WITH data AS (SELECT * FROM orders) SELECT * FROM data
238238
**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.
239239

240240
**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`.

docs/permission-system.md

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ Permissions are defined as **policies**. A policy is a named, reusable unit that
88

99
When a user runs a query:
1010
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.
1313
4. The rewritten query executes against the upstream database.
1414

1515
## Policy effects
1616

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.
1919

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.
2121

2222
> **`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.
2323
>
@@ -57,23 +57,27 @@ Replaces a column's value with a masked expression in query results.
5757

5858
When multiple mask policies target the same column, the one with the **lowest priority number** (highest precedence) wins.
5959

60-
### column_access (deny)
60+
### column_access
6161

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.
6366

6467
```json
6568
{
6669
"schema": "public",
6770
"table": "customers",
68-
"columns": ["ssn", "credit_card"],
69-
"action": "deny"
71+
"columns": ["ssn", "credit_card"]
7072
}
7173
```
7274

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.
7476

7577
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.
7678

79+
Glob patterns are supported in the `columns` field — see [Column glob patterns](#column-glob-patterns-columns-field) below.
80+
7781
### object_access (deny)
7882

7983
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.
@@ -103,7 +107,7 @@ Unlike `column_access deny` (which strips columns from results), `object_access
103107

104108
> **`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.
105109
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.
107111
108112
## Template variables
109113

@@ -173,47 +177,46 @@ Column visibility follows a **zero-trust** model in `policy_required` mode: a ta
173177

174178
### Obligation responsibility matrix
175179

176-
| Obligation | Grants table access? | Grants column access? | Modifies data? |
177-
|---|---|---|---|
178-
| `row_filter` | **No** | No | Yes (filters rows) |
179-
| `column_mask` | **No** | No | Yes (transforms value) |
180-
| `column_access "allow"` | **Yes** | Yes (named columns only) | No |
181-
| `column_access "deny"` | **No** — does not unblock a table | Removes named columns | No |
182-
| `object_access "deny"` | Removes table/schema | Removes all | No |
180+
| Obligation | Policy effect | Grants table access? | Grants column access? | Modifies data? |
181+
|---|---|---|---|---|
182+
| `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 |
183187

184-
### `column_access "allow"` — the access grant obligation
188+
### `column_access` in a permit policy — the access grant obligation
185189

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:
187191

188192
```json
189193
{
190194
"schema": "public",
191195
"table": "customers",
192-
"columns": ["id", "name", "email"],
193-
"action": "allow"
196+
"columns": ["id", "name", "email"]
194197
}
195198
```
196199

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.
198201

199202
### Composing access with row filters
200203

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:
202205

203206
```json
204207
[
205-
{ "type": "column_access", "action": "allow", "schema": "public", "table": "customers", "columns": ["id", "name"] },
208+
{ "type": "column_access", "schema": "public", "table": "customers", "columns": ["id", "name"] },
206209
{ "type": "row_filter", "schema": "public", "table": "customers", "filter_expression": "organization_id = {user.tenant}" }
207210
]
208211
```
209212

210213
Result: only `id` and `name` columns, filtered to the user's tenant rows.
211214

212-
### `column_access "deny"` does not grant access
215+
### `column_access` in a deny policy does not grant access
213216

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.
215218

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.
217220

218221
### JOIN column scoping
219222

@@ -443,16 +446,15 @@ Partially mask SSNs for support staff:
443446
```
444447
445448
### Column access control (DS-10)
446-
Remove sensitive columns entirely:
449+
Remove sensitive columns entirely using a deny policy:
447450
```yaml
448451
- name: hide-pii
449-
effect: permit
452+
effect: deny
450453
obligations:
451454
- type: column_access
452455
schema: public
453456
table: customers
454457
columns: [ssn, credit_card]
455-
action: deny
456458
```
457459
458460
### Admin full access (MT-05)

docs/roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,11 @@ Each obligation type gets an optional `condition` field:
216216
"definition": { "schema": "hr", "table": "employees", "column": "ssn", "mask_expression": "'***-**-****'" }
217217
}
218218

219-
// Column access deny - hide columns conditionally
219+
// Column access deny - hide columns conditionally (policy effect = "deny")
220220
{
221221
"obligation_type": "column_access",
222222
"condition": "user.clearance_level < 5",
223-
"definition": { "schema": "secret", "table": "files", "action": "deny", "columns": ["content"] }
223+
"definition": { "schema": "secret", "table": "files", "columns": ["content"] }
224224
}
225225

226226
// Object access deny - hide tables conditionally

proxy/CLAUDE.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,40 @@ Policy CRUD handlers also call `state.proxy_handler.rebuild_contexts_for_datasou
7575

7676
**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).
7777

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+
78106
## 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.
81112

82113
## Known Issues
83114
- **regclass / regproc not supported**`datafusion-table-providers` drops these columns. Catalog stores `arrow_type = NULL`; `build_arrow_schema` skips them.

proxy/src/admin/dto.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,8 @@ pub fn validate_obligation(obl: &ObligationRequest) -> Result<(), String> {
358358
require_str_field(def, "column_mask", "mask_expression")?;
359359
}
360360
ObligationType::ColumnAccess => {
361+
// Intent (allowlist vs denylist) is determined by the enclosing policy's effect.
362+
// No obligation-level 'action' field is required or accepted.
361363
require_str_field(def, "column_access", "schema")?;
362364
require_str_field(def, "column_access", "table")?;
363365
match def.get("columns") {
@@ -374,14 +376,6 @@ pub fn validate_obligation(obl: &ObligationRequest) -> Result<(), String> {
374376
);
375377
}
376378
}
377-
let action = def.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
378-
"column_access obligation: missing required field 'action'".to_string()
379-
})?;
380-
if action != "allow" && action != "deny" {
381-
return Err(
382-
"column_access obligation: 'action' must be 'allow' or 'deny'".to_string(),
383-
);
384-
}
385379
}
386380
ObligationType::ObjectAccess => {
387381
require_str_field(def, "object_access", "schema")?;

0 commit comments

Comments
 (0)