Skip to content

Commit fe19090

Browse files
chore: refine policy enforcement, demo scripts, and tooling
Update proxy policy hooks and enforcement tests, refresh the ecommerce demo (compose file, setup script, schema, policies, seed), polish admin UI forms, and extend CLAUDE.md guidance plus pre-commit and CI config.
1 parent e0a6ce4 commit fe19090

23 files changed

Lines changed: 2914 additions & 621 deletions

.claude/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ Every feature plan MUST include a comprehensive test case inventory before imple
7171

7272
**Test naming convention:** Group tests by category with descriptive names. Map security-relevant tests to vector numbers in `docs/security-vectors.md`.
7373

74+
**Adding a new security vector:** When a new feature or bug fix touches access control, policy resolution, or data visibility, add (or update) an entry in `docs/security-vectors.md` using the standard schema defined at the top of that file. Section order is fixed: `**Vector**``**Attacks**``**Defense**``**Previously**` *(only if strengthening an earlier defense)*`**Status**` *(only for unmitigated threats or accepted trade-offs)*`**Tests**`. Every attack variant must either have a test back-reference (`— attack N`) in the `**Tests**` section or be explicitly marked under `**Status**`. Never use `**Bug**` as a section label — historical fix context goes in `**Previously**` in past tense. See the top of `docs/security-vectors.md` for the full schema description and `### 13` for a canonical example with multiple attack variants plus a `Previously` section.
75+
7476
### Security-First Thinking
7577
This is a data security product. Every feature that touches access control, policy resolution, or data visibility must be evaluated through a security lens:
7678
- **What can an attacker do?** — enumerate bypass vectors before building defenses

.githooks/pre-commit

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,15 @@ echo "→ admin-ui typecheck"
2222

2323
echo "→ admin-ui tests"
2424
(cd admin-ui && npm run test:run)
25+
26+
# Only run docs-site checks if files under docs-site/ are staged AND the
27+
# docs-site deps are installed (first clone won't have them yet).
28+
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^docs-site/'; then
29+
if [ -d "docs-site/node_modules" ]; then
30+
echo "→ docs-site vitepress build"
31+
(cd docs-site && npm run build)
32+
else
33+
echo "→ docs-site has staged changes but node_modules is missing; skipping build check"
34+
echo " Run 'cd docs-site && npm ci' once to enable docs checks in the hook."
35+
fi
36+
fi

.github/workflows/cicd.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ jobs:
4444
run: npm run test:run
4545
working-directory: admin-ui
4646

47+
docs-site:
48+
if: github.event_name == 'push'
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v5
52+
53+
- name: Set up Node
54+
uses: actions/setup-node@v5
55+
with:
56+
node-version: 22
57+
cache: npm
58+
cache-dependency-path: docs-site/package-lock.json
59+
60+
- name: Install docs-site dependencies
61+
run: npm ci
62+
working-directory: docs-site
63+
64+
- name: VitePress build
65+
run: npm run build
66+
working-directory: docs-site
67+
4768
publish:
4869
needs: test
4970
if: startsWith(github.ref, 'refs/tags/v')

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **[Proxy] BREAKING: `ctx.query.tables` is now an array of objects, not strings** — decision functions with `evaluate_context = "query"` previously received `ctx.query.tables` as `string[]` (e.g. `["public.orders"]`). It is now `Array<{datasource, schema, table}>`, so decision function JS must access the fields explicitly. Bare references like `SELECT * FROM orders` now also resolve to the session's default schema (e.g. `public`) rather than an empty schema segment, so qualified and unqualified references produce identical entries.
13+
14+
Migration for any decision function that inspected `ctx.query.tables`:
15+
16+
```js
17+
// Before:
18+
ctx.query.tables.includes("public.orders")
19+
ctx.query.tables.some(t => t.startsWith("public."))
20+
21+
// After:
22+
ctx.query.tables.some(t => t.schema === "public" && t.table === "orders")
23+
ctx.query.tables.some(t => t.schema === "public")
24+
```
25+
1026
## [0.14.1] - 2026-04-09
1127

1228
### Changed

admin-ui/src/components/CatalogDiscoveryWizard.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,15 @@ export function CatalogDiscoveryWizard({ datasourceId }: Props) {
198198
}
199199

200200
async function runSaveCatalog() {
201+
// TODO(rename-warning): if any entry in schemaAliases differs from the
202+
// existing schema_alias on the already-persisted schema, surface the
203+
// impact of the rename to the admin before saving — schema alias changes
204+
// break SQL queries using the old alias, decision function JS matching on
205+
// `ctx.query.tables[*].schema`, stored queries/dashboards, and audit logs
206+
// tagged with the old alias. Policy *enforcement* continues to work
207+
// because `matches_table` resolves aliases to upstream schema names via
208+
// `df_to_upstream` at session build time. See `docs/permission-system.md`
209+
// → "Rename fragility and label-based identifiers".
201210
setError(null)
202211
setProgress(null)
203212
setIsWorking(true)

admin-ui/src/components/DecisionFunctionModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ function buildCtxCompletions(evaluateContext: EvaluateContext, configStr: string
7171

7272
if (evaluateContext === 'query') {
7373
items.push(
74-
{ label: 'ctx.query.tables', type: 'variable' as const, detail: 'string[]' },
74+
{ label: 'ctx.query.tables', type: 'variable' as const, detail: '{datasource, schema, table}[]' },
75+
{ label: 'ctx.query.tables[0].datasource', type: 'variable' as const, detail: 'string' },
76+
{ label: 'ctx.query.tables[0].schema', type: 'variable' as const, detail: 'string' },
77+
{ label: 'ctx.query.tables[0].table', type: 'variable' as const, detail: 'string' },
7578
{ label: 'ctx.query.columns', type: 'variable' as const, detail: 'string[]' },
7679
{ label: 'ctx.query.join_count', type: 'variable' as const, detail: 'number' },
7780
{ label: 'ctx.query.has_aggregation', type: 'variable' as const, detail: 'boolean' },

admin-ui/src/components/PolicyForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export function PolicyForm({ initial, onSubmit, submitLabel, isSubmitting, error
276276
const [attrDefs, setAttrDefs] = useState<AttributeDefinition[]>([])
277277
useEffect(() => {
278278
listAttributeDefinitions({ entity_type: 'user', page_size: 200 })
279-
.then((res) => setAttrDefs(res.data))
279+
.then((res) => setAttrDefs(res.data ?? []))
280280
.catch(() => {})
281281
}, [])
282282
const templateItems = useMemo(

admin-ui/src/pages/DataSourceEditPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export function DataSourceEditPage() {
2929
is_active: boolean
3030
access_mode?: string
3131
}) {
32+
// TODO(rename-warning): if values.name differs from ds.name, surface the
33+
// impact of the rename to the admin before committing — datasource name
34+
// changes break SQL 3-part references (`oldName.schema.table`), connection
35+
// strings, decision function JS matching on `ctx.query.tables[*].datasource`
36+
// or `ctx.session.datasource.name`, and audit log consumers keyed on the
37+
// old name. Policy *enforcement* continues to work because policies are
38+
// assigned by datasource_id. See `docs/permission-system.md` → "Rename
39+
// fragility and label-based identifiers" for the full impact matrix.
3240
setIsSubmitting(true)
3341
try {
3442
await updateDataSource(dsId, {

admin-ui/vitest.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ export default defineConfig({
55
plugins: [react()],
66
test: {
77
environment: 'jsdom',
8+
// Pin jsdom's document URL to a closed TCP port. Axios in our client
9+
// uses baseURL: '/api/v1', which jsdom resolves against window.location.
10+
// Vitest's default http://localhost:3000/ means any unmocked test call
11+
// leaks to whatever happens to listen on :3000 (e.g. another vite dev
12+
// server on the same machine), which returns HTML for any path and
13+
// crashes components that try to .map()/.length the "response". Port 1
14+
// is guaranteed closed in practice — axios gets connection-refused in
15+
// ~1ms, the query enters error state, and defensive render paths kick in.
16+
environmentOptions: {
17+
jsdom: { url: 'http://localhost:1/' },
18+
},
819
globals: true,
920
css: false,
1021
setupFiles: ['./src/test/setup.ts'],

docs/permission-system.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,33 @@ function evaluate(ctx, config) {
488488

489489
`time.now` is an ISO 8601 / RFC 3339 timestamp representing the **evaluation time** — the moment the context is built. For visibility-level functions this is when the connection context is computed; for query-level functions it is when the query is processed. This enables time-windowed decision functions (e.g., break-glass temporary access).
490490

491+
#### `ctx.query.tables` — structured table references
492+
493+
Each entry in `ctx.query.tables` is an object with three string fields:
494+
495+
```js
496+
ctx.query.tables; // Array of { datasource, schema, table }
497+
```
498+
499+
| Field | What it contains |
500+
|-------|------------------|
501+
| `datasource` | The BetweenRows datasource name the session is connected to (e.g. `"prod"`). Same value as `ctx.session.datasource.name`. |
502+
| `schema` | The schema alias the user referenced (or the upstream schema name if no alias is configured). For bare references like `FROM orders`, this is the session's default schema (e.g. `"public"`) — **never** an empty string. |
503+
| `table` | The table name as referenced in the SQL. |
504+
505+
A bare reference `SELECT * FROM orders JOIN users` and a qualified reference `SELECT * FROM public.orders JOIN public.users` produce **identical** `ctx.query.tables` arrays, so decision function logic doesn't need to handle both forms:
506+
507+
```js
508+
// Matches regardless of whether the user wrote bare `orders` or `public.orders`
509+
const touchesOrders = ctx.query.tables.some(
510+
t => t.schema === "public" && t.table === "orders"
511+
);
512+
```
513+
514+
System tables (`pg_catalog.*`, `information_schema.*`) are filtered out of `ctx.query.tables` entirely — decision functions never see them.
515+
516+
**Identifier stability**: `datasource` and `schema` are **user-facing labels** that admins can rename (see "Rename fragility and label-based identifiers" below). If an admin renames the `prod` datasource to `production`, decision functions that hardcoded `t.datasource === "prod"` will silently stop matching. Prefer matching on `table` (which is stable with the upstream name) plus `schema` (which is generally stable unless an alias is reconfigured) for rules you want to survive renames. A future rename-warning UX will surface the impact of renames before admins commit them.
517+
491518
Custom user attributes are flattened as first-class fields on the `user` object with correctly typed values (string/number/boolean/array) — e.g., `ctx.session.user.region`, not `ctx.session.user.attributes.region`. This matches the flat `{user.KEY}` namespace used in template variables. In the API, the same attributes are nested under `attributes` (see [Namespace design](#namespace-design-flat-in-expressions-nested-in-api)). List attributes appear as JSON arrays of strings. Built-in fields (`id`, `username`, `roles`) always take priority. See [User Attributes (ABAC)](#user-attributes-abac) for details.
492519

493520
**Visibility-level enforcement**: `column_deny`, `table_deny`, and `column_allow` policies are enforced at connect time (visibility level) by removing columns/tables from the per-user schema. Decision functions on these policy types are evaluated at visibility time when `evaluate_context = "session"`. If the decision function returns `fire: false`, the policy is skipped and the column/table remains visible. For `evaluate_context = "query"`, the policy's visibility effect is skipped entirely (deferred to query time), since query metadata is not available at connect time — the column/table stays visible in the schema and the decision function runs at query time as normal.
@@ -1307,4 +1334,27 @@ Attach this to a `table_deny` policy targeting `executive_comp` — executives s
13071334
| Tiered masking by clearance | Nested `CASE WHEN` in `column_mask` | `CASE WHEN {user.clearance} >= 5 THEN val WHEN ... END` |
13081335
| Mask using row data + user attributes | `CASE WHEN` referencing both | `CASE WHEN region = {user.region} THEN val ELSE ... END` |
13091336
| Conditional deny/allow | Decision function | `{ fire: ctx.session.user.team !== 'x' }` |
1337+
1338+
## Rename fragility and label-based identifiers
1339+
1340+
BetweenRows uses **user-facing labels** for identifiers everywhere users touch the system: datasource names, schema aliases, policy names, and decision function contexts. This is intentional — stable IDs (UUIDs) would be unusable as SQL identifiers and would force admins to hand-write UUIDs in policy targets.
1341+
1342+
The tradeoff is that **renames are breaking changes** for anything that references the old label. Three rename operations matter:
1343+
1344+
| Rename | What breaks | What keeps working |
1345+
|--------|-------------|--------------------|
1346+
| **Datasource name** (e.g. `prod` → `production`) | Connection strings (`dbname=prod`); 3-part SQL references (`prod.public.orders`); `ctx.session.datasource.name` checks; `ctx.query.tables[*].datasource` checks in decision function JS; audit log rows tagged with the old name | Policy enforcement for in-flight queries: policies are assigned by `datasource.id` (UUID), not name, so they continue to match after the rename |
1347+
| **Schema alias** (e.g. `public` → `main`) | SQL queries using the old alias; `ctx.query.tables[*].schema` checks; `{user.KEY}` expressions that embed the alias; dashboards and stored queries | Policy target matching: `TargetEntry::matches_table` resolves the new alias to the upstream schema name via `df_to_upstream` at session build time, and policy targets store upstream names — so enforcement is stable across alias renames |
1348+
| **Upstream schema or database rename** (performed in the actual Postgres, not BR) | Everything that references it — catalog discovery must be re-run, and `df_to_upstream` entries rebuilt | Policies need to be retargeted manually |
1349+
1350+
**Guidance for decision function authors**: prefer matching on `t.table` (stable with upstream) plus `t.schema` (usually stable unless an alias is reconfigured) over `t.datasource`. If a rule must survive datasource renames, gate it on `ctx.session.user.roles` or attributes, not on datasource identity.
1351+
1352+
**Guidance for admins**: when renaming a datasource or schema alias, audit existing:
1353+
1354+
1. Decision function JS for references to the old name
1355+
2. Stored queries, dashboards, and SQL snippets in external tools (BR cannot see these)
1356+
3. Connection strings in deployed applications
1357+
4. Audit logs and any downstream log aggregators keyed on the old name
1358+
1359+
A future rename-warning feature will surface a list of affected entities before the rename commits. Until then, renames should be treated as a coordinated operation, not a casual UI edit. Policy *enforcement* (the security layer) is the one thing that stays correct across renames automatically.
13101360
| Combine multiple filters | Separate policies (AND-combined) | Two `row_filter` policies on same table |

0 commit comments

Comments
 (0)