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
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
8
8
## [Unreleased]
9
9
10
+
## [0.17.0] - 2026-04-23
11
+
10
12
### Added
11
13
12
14
-**[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.
Copy file name to clipboardExpand all lines: docs-site/docs/guides/attributes.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -55,7 +55,7 @@ Go to **Attribute Definitions → Create** in the admin UI:
55
55
-**Default value:** (leave empty — users without a tenant should match nothing)
56
56
-**Description:** "Which customer tenant this user belongs to"
57
57
58
-

58
+

59
59
60
60
### 2. Assign the attribute to a user
61
61
@@ -69,7 +69,7 @@ Edit a user (e.g., `alice`) and set her attributes:
69
69
70
70
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.
71
71
72
-

72
+

73
73
74
74
### 3. Use the attribute in a policy expression
75
75
@@ -188,4 +188,4 @@ If a policy references `{user.foo}` but no attribute definition named `foo` exis
188
188
-[Users & Roles](/guides/users-roles) — how users and roles are managed
189
189
-[Row Filters](/guides/policies/row-filters) — the most common consumer of user attributes
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.
74
74
@@ -160,4 +160,4 @@ Think of the catalog as "what can potentially exist" and policies as "what each
160
160
-[Policies overview](/guides/policies/) — which policy types to layer on top of the data source
161
161
-[Rename Safety](/operations/rename-safety) — what breaks when you rename
Copy file name to clipboardExpand all lines: docs-site/docs/guides/decision-functions.md
+4-4Lines changed: 4 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -153,11 +153,11 @@ Goal: a `table_deny` policy on `salary_data` that only fires outside business ho
153
153
}
154
154
```
155
155
156
-

156
+

157
157
158
158
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.
159
159
160
-

160
+

161
161
162
162
3.**Create the policy** — a `table_deny` on `salary_data` — and attach the decision function via `decision_function_id`.
163
163
@@ -235,7 +235,7 @@ The response tells you:
235
235
236
236
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.
237
237
238
-

238
+

239
239
240
240
### Logging with `log_level`
241
241
@@ -385,4 +385,4 @@ If a user has 5 policies and 3 have query-context decision functions, each query
385
385
-[Policies overview](/guides/policies/) — which policy type to attach a decision function to
386
386
-[Template Expressions](/reference/template-expressions) — the simpler alternative for attribute-based logic
Copy file name to clipboardExpand all lines: docs-site/docs/guides/policies/row-filters.md
+63-5Lines changed: 63 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -43,13 +43,13 @@ Go to **Policies → Create**:
43
43
-**Targets:** schemas `*`, tables `*` (applies to all tables with an `org` column)
44
44
-**Filter expression:**`org = {user.tenant}`
45
45
46
-

46
+

47
47
48
48
### 3. Assign the policy
49
49
50
50
On the data source page, assign `tenant-isolation` with **scope: All users**.
51
51
52
-

52
+

53
53
54
54
### 4. Verify
55
55
@@ -73,7 +73,7 @@ Open **Query Audit** in the admin UI. Alice's query shows:
73
73
-**Rewritten:**`SELECT DISTINCT org FROM orders WHERE org = 'acme'`
74
74
-**Policies applied:**`tenant-isolation (v1)`
75
75
76
-

76
+

77
77
78
78
## Patterns and recipes
79
79
@@ -117,6 +117,64 @@ org = {user.tenant} AND status != 'deleted'
117
117
118
118
A single expression can combine attribute-based and static conditions.
119
119
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
+
120
178
## Composition
121
179
122
180
### Multiple row filters → AND
@@ -153,7 +211,7 @@ If a `table_deny` hides a table, row filters on that table are irrelevant — th
153
211
154
212
-**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.
155
213
-**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.
157
215
158
216
→ Full diagnostics: [Audit & Debugging](/guides/audit-debugging) · [Troubleshooting](/operations/troubleshooting)
159
217
@@ -164,4 +222,4 @@ If a `table_deny` hides a table, row filters on that table are irrelevant — th
164
222
-[Template Expressions](/reference/template-expressions) — full expression syntax and NULL semantics
165
223
-[User Attributes](/guides/attributes) — how to define and assign the attributes that drive filters
0 commit comments