Skip to content

Commit 0efec6f

Browse files
Release v0.17.0
1 parent 7d5550d commit 0efec6f

69 files changed

Lines changed: 137 additions & 41 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.17.0] - 2026-04-23
11+
1012
### Added
1113

1214
- **[Admin UI] Design standard and shared layout primitives** — new `admin-ui/DESIGN.md` defines four page templates (T1 sidebar detail, T2 single form, T3 list, T4 audit) plus the header / save / destructive / modal rules they share. Eight new primitives land under `src/components/` and `src/components/layout/`: `PageHeader`, `SecondaryNav` + `useSectionParam`, `SectionPane`, `DangerZone` + `DangerRow`, `ModalShell` (with focus trap, Esc, click-outside), `StatusDot` + `StatusChip`, `ConfirmDeleteModal`, and `ConfirmDialog`. Each ships with unit tests. `admin-ui/CLAUDE.md` gained a "Design standard" pointer so future sessions pick up the rules before building a new page.

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "admin-ui",
33
"private": true,
4-
"version": "0.16.2",
4+
"version": "0.17.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

docs-site/docs/.vitepress/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ export const OG_IMAGE_URL = `${WWW_URL}/og-image.png`
1414
// Current released proxy version. Bumped by /release alongside Cargo.toml
1515
// and admin-ui/package.json. Substituted into markdown via the {{VERSION}}
1616
// token — see the markdown.config hook in .vitepress/config.ts.
17-
export const VERSION = '0.16.2'
17+
export const VERSION = '0.17.0'

docs-site/docs/guides/attributes.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Go to **Attribute Definitions → Create** in the admin UI:
5555
- **Default value:** (leave empty — users without a tenant should match nothing)
5656
- **Description:** "Which customer tenant this user belongs to"
5757

58-
![Attribute definition form for the tenant attribute](/screenshots/attributes-def-form-v0.15.png)
58+
![Attribute definition form for the tenant attribute](/screenshots/attributes-def-form-v0.17.png)
5959

6060
### 2. Assign the attribute to a user
6161

@@ -69,7 +69,7 @@ Edit a user (e.g., `alice`) and set her attributes:
6969

7070
Attribute assignment uses **full-replace semantics** — the entire attributes object is overwritten on each update. To add a new attribute, include all existing ones in the payload.
7171

72-
![Assigning attribute values to a user in the admin UI](/screenshots/attributes-assignment-v0.15.png)
72+
![Assigning attribute values to a user in the admin UI](/screenshots/attributes-assignment-v0.17.png)
7373

7474
### 3. Use the attribute in a policy expression
7575

@@ -188,4 +188,4 @@ If a policy references `{user.foo}` but no attribute definition named `foo` exis
188188
- [Users & Roles](/guides/users-roles) — how users and roles are managed
189189
- [Row Filters](/guides/policies/row-filters) — the most common consumer of user attributes
190190

191-
<!-- screenshots: [attributes-def-form-v0.15.png, attributes-assignment-v0.15.png] -->
191+
<!-- screenshots: [attributes-def-form-v0.17.png, attributes-assignment-v0.17.png] -->

docs-site/docs/guides/audit-debugging.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ A row is written for every mutation to the admin-plane state: users, roles, poli
100100
- **No WHERE clause:** the template variable may have resolved to NULL. Check Alice's `tenant` attribute value.
101101
- **Wrong value:** check which user attribute value Alice has set.
102102

103-
![Query audit detail view showing rewritten SQL and applied policies](/screenshots/audit-debugging-query-detail-v0.15.png)
103+
![Query audit detail view showing rewritten SQL and applied policies](/screenshots/audit-debugging-query-detail-v0.17.png)
104104

105105
### Scenario 2: zero rows returned
106106

@@ -147,7 +147,7 @@ A row is written for every mutation to the admin-plane state: users, roles, poli
147147
- **Action**`create`, `update`, `delete`, `assign`, `unassign`, etc.
148148
- **Changes** — JSON diff of what changed (before/after for updates, full snapshot for create/delete)
149149

150-
![Admin audit entry showing actor, action, and change diff](/screenshots/audit-debugging-admin-detail-v0.15.png)
150+
![Admin audit entry showing actor, action, and change diff](/screenshots/audit-debugging-admin-detail-v0.17.png)
151151

152152
## Patterns and recipes
153153

@@ -204,4 +204,4 @@ BetweenRows is read-only. If a client sends `DELETE FROM orders`, the proxy reje
204204
- [Policies overview](/guides/policies/) — understanding what fires and why
205205
- [Troubleshooting](/operations/troubleshooting) — connection and policy diagnostic trees
206206

207-
<!-- screenshots: [audit-debugging-query-detail-v0.15.png, audit-debugging-admin-detail-v0.15.png, audit-debugging-filter-panel-v0.15.png] -->
207+
<!-- screenshots: [audit-debugging-query-detail-v0.17.png, audit-debugging-admin-detail-v0.17.png] -->

docs-site/docs/guides/data-sources.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ These steps use the [demo schema](/reference/demo-schema) as an example. Substit
4848
| Password | `postgres` |
4949
| SSL mode | `disable` (local dev only) |
5050

51-
![Data source connection form with demo PostgreSQL details](/screenshots/data-sources-connection-form-v0.15.png)
51+
![Data source connection form with demo PostgreSQL details](/screenshots/data-sources-connection-form-v0.17.png)
5252

5353
3. **Click Test Connection.** A green indicator confirms the proxy can reach the upstream. If it fails, check:
5454
- Host/port reachable from the BetweenRows container (not just your laptop)
5555
- Username/password correct for the upstream database
5656
- SSL mode matches the upstream's `pg_hba.conf` settings
5757
- Firewall/security group allows the connection
5858

59-
![Successful connection test indicator on the data source form](/screenshots/data-sources-test-success-v0.15.png)
59+
![Successful connection test indicator on the data source form](/screenshots/data-sources-test-success-v0.17.png)
6060

6161
4. **Save** the data source.
6262

@@ -67,8 +67,8 @@ These steps use the [demo schema](/reference/demo-schema) as an example. Substit
6767
- **Columns** — for each selected table, select which columns to expose. Deselect columns here as a first-pass data-minimization step.
6868
- **Save** — persist the selections as the baseline catalog.
6969

70-
![Catalog discovery wizard showing schema selection step](/screenshots/data-sources-discover-schemas-v0.15.png)
71-
![Catalog discovery wizard showing column selection step](/screenshots/data-sources-discover-columns-v0.15.png)
70+
![Catalog discovery wizard showing schema selection step](/screenshots/data-sources-discover-schemas-v0.17.png)
71+
![Catalog discovery wizard showing column selection step](/screenshots/data-sources-discover-columns-v0.17.png)
7272

7373
6. **Grant user access.** On the data source page, add users or roles in the **User Access** section. Admin status does **not** grant data access — every user starts with zero data plane access.
7474

@@ -160,4 +160,4 @@ Think of the catalog as "what can potentially exist" and policies as "what each
160160
- [Policies overview](/guides/policies/) — which policy types to layer on top of the data source
161161
- [Rename Safety](/operations/rename-safety) — what breaks when you rename
162162

163-
<!-- screenshots: [data-sources-connection-form-v0.15.png, data-sources-test-success-v0.15.png, data-sources-discover-schemas-v0.15.png, data-sources-discover-columns-v0.15.png] -->
163+
<!-- screenshots: [data-sources-connection-form-v0.17.png, data-sources-test-success-v0.17.png, data-sources-discover-schemas-v0.17.png, data-sources-discover-columns-v0.17.png] -->

docs-site/docs/guides/decision-functions.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,11 @@ Goal: a `table_deny` policy on `salary_data` that only fires outside business ho
153153
}
154154
```
155155

156-
![Decision function editor with business hours JavaScript source](/screenshots/decision-functions-editor-v0.15.png)
156+
![Decision function editor with business hours JavaScript source](/screenshots/decision-functions-editor-v0.17.png)
157157

158158
2. **Test the function** using the built-in test runner (`POST /decision-functions/test`). Provide a mock context with different hours/days and verify the `fire` result matches expectations.
159159

160-
![Decision function test runner with mock session context](/screenshots/decision-functions-test-runner-v0.15.png)
160+
![Decision function test runner with mock session context](/screenshots/decision-functions-test-runner-v0.17.png)
161161

162162
3. **Create the policy** — a `table_deny` on `salary_data` — and attach the decision function via `decision_function_id`.
163163

@@ -235,7 +235,7 @@ The response tells you:
235235

236236
Test with different mock contexts to cover your edge cases: different users, different times, different roles, missing attributes. The test runner compiles and executes the JS in the same WASM sandbox used in production — it's not a simulation.
237237

238-
![Decision function test runner result showing fire value and logs](/screenshots/decision-functions-test-runner-v0.15.png)
238+
![Decision function test runner result showing fire value and logs](/screenshots/decision-functions-test-runner-v0.17.png)
239239

240240
### Logging with `log_level`
241241

@@ -385,4 +385,4 @@ If a user has 5 policies and 3 have query-context decision functions, each query
385385
- [Policies overview](/guides/policies/) — which policy type to attach a decision function to
386386
- [Template Expressions](/reference/template-expressions) — the simpler alternative for attribute-based logic
387387

388-
<!-- screenshots: [decision-functions-editor-v0.15.png, decision-functions-test-runner-v0.15.png] -->
388+
<!-- screenshots: [decision-functions-editor-v0.17.png, decision-functions-test-runner-v0.17.png] -->

docs-site/docs/guides/policies/column-masks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Go to **Policies → Create**:
3636
- **Targets:** schema `public`, table `customers`, column `ssn`
3737
- **Mask expression:** `'***-**-' || RIGHT(ssn, 4)`
3838

39-
![Column mask policy editor showing SSN masking expression](/screenshots/column-masks-editor-v0.15.png)
39+
![Column mask policy editor showing SSN masking expression](/screenshots/column-masks-editor-v0.17.png)
4040

4141
### 2. Assign and verify
4242

@@ -146,4 +146,4 @@ If a `column_deny` removes a column, a mask on the same column is irrelevant —
146146
- [Column Allow & Deny](./column-allow-deny) — for removing columns entirely
147147
- [Template Expressions](/reference/template-expressions) — expression syntax and `{user.KEY}` variables
148148

149-
<!-- screenshots: [column-masks-editor-v0.15.png, column-masks-result-v0.15.png] -->
149+
<!-- screenshots: [column-masks-editor-v0.17.png, column-masks-result-v0.15.png] -->

docs-site/docs/guides/policies/row-filters.md

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ Go to **Policies → Create**:
4343
- **Targets:** schemas `*`, tables `*` (applies to all tables with an `org` column)
4444
- **Filter expression:** `org = {user.tenant}`
4545

46-
![Row filter policy editor with tenant isolation expression](/screenshots/row-filters-policy-editor-v0.15.png)
46+
![Row filter policy editor with tenant isolation expression](/screenshots/row-filters-policy-editor-v0.17.png)
4747

4848
### 3. Assign the policy
4949

5050
On the data source page, assign `tenant-isolation` with **scope: All users**.
5151

52-
![Assigning a row filter policy to a data source with all-users scope](/screenshots/row-filters-assignment-v0.15.png)
52+
![Assigning a row filter policy to a data source with all-users scope](/screenshots/row-filters-assignment-v0.17.png)
5353

5454
### 4. Verify
5555

@@ -73,7 +73,7 @@ Open **Query Audit** in the admin UI. Alice's query shows:
7373
- **Rewritten:** `SELECT DISTINCT org FROM orders WHERE org = 'acme'`
7474
- **Policies applied:** `tenant-isolation (v1)`
7575

76-
![Query audit entry showing the injected WHERE clause from tenant isolation](/screenshots/row-filters-audit-v0.15.png)
76+
![Query audit entry showing the injected WHERE clause from tenant isolation](/screenshots/row-filters-audit-v0.17.png)
7777

7878
## Patterns and recipes
7979

@@ -117,6 +117,64 @@ org = {user.tenant} AND status != 'deleted'
117117

118118
A single expression can combine attribute-based and static conditions.
119119

120+
## Filtering by a column on a parent table
121+
122+
In a normalized schema, a scope column like `org`, `tenant_id`, or `workspace_id` rarely lives on every tenant-scoped table. A `customers` table has `org`, but `support_tickets` only has `customer_id` and reaches `org` through the join. A single broad `row_filter` like `org = {user.tenant}` cannot apply to both tables unless the proxy knows how to follow the foreign key. **Column anchors** tell it how.
123+
124+
A column anchor has two shapes:
125+
126+
- **FK walk** — the column lives on a parent table reachable via a foreign key. The proxy injects an `INNER JOIN` against the parent and rewrites the plan to `Project([target.*], Filter(expr, InnerJoin(target, parent)))`. The top projection re-emits the target's scan columns, so downstream plan nodes (including column masks) are unaffected. `INNER JOIN` semantics drop child rows whose FK is NULL, which matches the intent of tenant isolation.
127+
- **Same-table alias** — the column lives on the target table itself but under a different name (e.g., `customers.tenant_id` vs `accounts.org_id` for the same concept). The proxy rewrites the filter expression in place with no join.
128+
129+
Exactly one shape per anchor. Exactly one anchor per `(table, column)` — enforced by a partial unique index, so resolution is deterministic by construction.
130+
131+
### Register a foreign key as a relationship
132+
133+
For the FK-walk shape, first register the join path. Go to **Data Sources → edit → Relationships**:
134+
135+
- Click **Show FK suggestions** to see live candidates introspected from the upstream database. Only single-column FKs whose parent column is a primary key or single-column unique appear (the "at-most-one-parent-per-child" precondition) — clicking **Add** promotes a suggestion into a `table_relationship`.
136+
- Or click **Add manually** when the upstream FK is missing or you want a different join path.
137+
138+
For the same-table alias shape, no relationship is needed.
139+
140+
### Designate a column anchor
141+
142+
On the same datasource page, go to the **Column anchors** section and click **Add anchor**:
143+
144+
- **Child table** — the table the row filter targets (e.g., `support_tickets`).
145+
- **Resolved column** — the name used inside the filter expression (e.g., `org`).
146+
- **Resolve via** — pick **Relationship (FK walk)** to choose a registered relationship, or **Same-table alias** to enter the real column name on the target (e.g., `org_id`).
147+
148+
For a multi-hop walk (e.g., `support_tickets → orders → customers` where `org` lives on `customers`), register one anchor per hop. The resolver walks the chain up to three hops deep.
149+
150+
### Check coverage before shipping
151+
152+
The policy edit page shows an **Anchor coverage** section for every `row_filter` policy. It dry-runs the same resolution the proxy will use at query time and reports one verdict per `(assigned table × column referenced in the filter)`:
153+
154+
- **Resolves on target** — the column is literally present on the target.
155+
- **Resolves via relationship** — the FK walk reaches a parent that carries the column. The anchor chain is shown.
156+
- **Resolves via alias** — the filter column will be rewritten to the alias on this target.
157+
- **No anchor configured** — silent-deny. The user sees zero rows on this table until you add an anchor. Links to the datasource page where you can fix it.
158+
- **Alias target column missing** — the alias points at a column that does not exist on the discovered catalog. Silent-deny.
159+
160+
A green banner means every pair resolves cleanly. A red panel lists the broken pairs. For non-`row_filter` policies the section is hidden.
161+
162+
### Fail-secure resolution
163+
164+
Every resolution failure produces `Filter(false)` — zero rows, no query error — plus a structured `tracing::warn!(reason="column_resolution_unresolved", ...)` for ops visibility. The five failure modes are:
165+
166+
1. **No anchor** on the target or an intermediate hop.
167+
2. **Walk too deep** — more than three hops.
168+
3. **Cycle** in the relationship graph.
169+
4. **Qualified parent reference** in the filter expression (`WHERE customers.org = ...`). v1 only supports unqualified column references.
170+
5. **Referenced column not on the target and not resolvable via any anchor** — also catches alias anchors whose target column does not exist.
171+
172+
Deny-wins on resolution failure means a mis-configured anchor leaks nothing, but it also means an authoring mistake is invisible until you check the coverage panel or run a query. Use the coverage panel before assigning the policy.
173+
174+
### Trust model
175+
176+
The anchor designation is the load-bearing trust assumption. A mis-designated anchor — an FK walk pointing at the wrong parent, or an alias pointing at an unrelated local column — can return a different tenant's rows than intended. The proxy cannot infer intent from the schema alone. Mitigations: the FK suggestions dropdown shows every live candidate (so admins see alternatives before choosing), the partial unique index removes nondeterministic path selection, and every relationship and anchor mutation flows through the admin audit log. See [Threat Model → vector 73](/concepts/threat-model) for the full write-up.
177+
120178
## Composition
121179

122180
### Multiple row filters → AND
@@ -153,7 +211,7 @@ If a `table_deny` hides a table, row filters on that table are irrelevant — th
153211

154212
- **Filter not applied** — check: policy `is_enabled`, assigned to the user's data source, target schemas/tables match the queried table, user has data source access.
155213
- **Zero rows when expecting data** — check: user has the required attribute set, attribute value matches the data, no conflicting row filter AND-combining to empty. Inspect the **rewritten query** in the audit log.
156-
- **Filter applied to wrong tables** — check target patterns. `schemas: ["*"], tables: ["*"]` matches everything; a table without the referenced column (e.g., no `org` column) will error at query time.
214+
- **Filter applied to wrong tables** — check target patterns. `schemas: ["*"], tables: ["*"]` matches everything. If a matched table does not carry the referenced column (e.g., no `org` column) and no column anchor is configured, the policy fails safe: the user sees zero rows and the proxy logs a `column_resolution_unresolved` warning. See [Filtering by a column on a parent table](#filtering-by-a-column-on-a-parent-table) for how to route the filter through a foreign key or rename.
157215

158216
→ Full diagnostics: [Audit & Debugging](/guides/audit-debugging) · [Troubleshooting](/operations/troubleshooting)
159217

@@ -164,4 +222,4 @@ If a `table_deny` hides a table, row filters on that table are irrelevant — th
164222
- [Template Expressions](/reference/template-expressions) — full expression syntax and NULL semantics
165223
- [User Attributes](/guides/attributes) — how to define and assign the attributes that drive filters
166224

167-
<!-- screenshots: [row-filters-policy-editor-v0.15.png, row-filters-assignment-v0.15.png, row-filters-audit-v0.15.png] -->
225+
<!-- screenshots: [row-filters-policy-editor-v0.17.png, row-filters-assignment-v0.17.png, row-filters-audit-v0.17.png] -->

0 commit comments

Comments
 (0)