Skip to content

Commit 0f0dd23

Browse files
Replace obligation model with flat policy_type, add 5 migrations, fix definition null bug
Flattens the policy model: drops policy_obligation table and merges targets/definition/policy_type directly onto the policy row. Adds 5 migrations (019-023) to drop old tables and recreate policy/version/ assignment with the new schema. Updates proxy engine, hooks, admin handlers, DTOs, and admin-UI to match the flat model. Also fixes a bug in update_policy where changing to a non-expression type (column_allow/deny/table_deny) left a stale definition in the DB; the handler now clears definition unconditionally for types that don't use one. Two unit tests were added to cover this.
1 parent a81d62f commit 0f0dd23

39 files changed

Lines changed: 2407 additions & 3517 deletions

.claude/agents/gemini-reviewer.md

Lines changed: 0 additions & 24 deletions
This file was deleted.

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ betweenrows/
173173
│ policy_handlers, audit_handlers, policy_yaml
174174
├── discovery/ DiscoveryProvider trait + Postgres impl
175175
├── entity/ SeaORM entities (proxy_user, data_source, policy,
176-
policy_obligation, policy_assignment, policy_version,
176+
│ policy_assignment, policy_version,
177177
│ query_audit_log, …)
178178
├── engine/mod.rs EngineCache, VirtualCatalogProvider, build_arrow_schema()
179179
└── hooks/ QueryHook trait, ReadOnlyHook, PolicyHook
@@ -424,8 +424,8 @@ All policy endpoints require admin (`is_admin = true`).
424424
| Method | Path | Description |
425425
|--------|------|-------------|
426426
| GET | `/policies` | List policies (paginated) |
427-
| POST | `/policies` | Create policy with obligations |
428-
| GET | `/policies/{id}` | Get policy + obligations + assignment count |
427+
| POST | `/policies` | Create policy |
428+
| GET | `/policies/{id}` | Get policy + assignment count |
429429
| PUT | `/policies/{id}` | Update policy (requires `version` for optimistic concurrency → 409 on conflict) |
430430
| DELETE | `/policies/{id}` | Delete policy (cascades) |
431431
| GET | `/policies/export` | Export all policies as YAML |
@@ -467,9 +467,8 @@ discovered_table (id UUID v5, discovered_schema_id, table_name, table_type, is_
467467
discovered_column (id UUID v5, discovered_table_id, column_name, ordinal_position,
468468
data_type, is_nullable, column_default, arrow_type)
469469
470-
policy (id UUID v7, name, description, effect, is_enabled, version, …)
470+
policy (id UUID v7, name, description, policy_type, is_enabled, version, targets JSON, definition JSON, …)
471471
policy_version (id UUID v7, policy_id, version, snapshot JSON, change_type, changed_by)
472-
policy_obligation (id UUID v7, policy_id, obligation_type, definition JSON)
473472
policy_assignment (id UUID v7, policy_id, data_source_id, user_id?, priority)
474473
query_audit_log (id UUID v7, user_id, username, data_source_id, datasource_name,
475474
original_query, rewritten_query, policies_applied JSON,

admin-ui/src/api/policies.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ describe('createPolicy', () => {
6868
mockClient.post.mockResolvedValue({ data: policy })
6969
const payload = {
7070
name: 'my-policy',
71-
effect: 'permit' as const,
71+
policy_type: 'row_filter' as const,
7272
is_enabled: true,
73-
obligations: [],
73+
targets: [{ schemas: ['public'], tables: ['orders'] }],
74+
definition: null,
7475
}
7576
const result = await createPolicy(payload)
7677
expect(mockClient.post).toHaveBeenCalledWith('/policies', payload)

admin-ui/src/components/PolicyAssignmentPanel.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ describe('PolicyAssignmentPanel', () => {
101101
})
102102

103103
it('populates policy dropdown from listPolicies', async () => {
104-
const policy = makePolicy({ id: 'p-1', name: 'deny-cols', effect: 'deny' })
104+
const policy = makePolicy({ id: 'p-1', name: 'deny-cols', policy_type: 'column_deny' })
105105
mockListPolicies.mockResolvedValue({ data: [policy], total: 1, page: 1, page_size: 100 })
106106
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
107-
await waitFor(() => expect(screen.getByText(/deny-cols.*deny/)).toBeInTheDocument())
107+
await waitFor(() => expect(screen.getByText(/deny-cols.*column_deny/)).toBeInTheDocument())
108108
})
109109

110110
it('populates user dropdown from listUsers', async () => {

admin-ui/src/components/PolicyAssignmentPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export function PolicyAssignmentPanel({ datasourceId }: PolicyAssignmentPanelPro
178178
<option value="">Select a policy…</option>
179179
{allPolicies.map((p) => (
180180
<option key={p.id} value={p.id}>
181-
{p.name} ({p.effect})
181+
{p.name} ({p.policy_type})
182182
</option>
183183
))}
184184
</select>

admin-ui/src/components/PolicyCodeView.test.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
22
import { screen, fireEvent } from '@testing-library/react'
33
import { renderWithProviders } from '../test/test-utils'
4-
import { makePolicy, makePolicyAssignment, makeObligation } from '../test/factories'
4+
import { makePolicy, makePolicyAssignment } from '../test/factories'
55
import { PolicyCodeView } from './PolicyCodeView'
66

77
beforeEach(() => {
@@ -36,18 +36,18 @@ describe('PolicyCodeView', () => {
3636
fireEvent.click(screen.getByText('View as code'))
3737
const pre = document.querySelector('pre')!
3838
expect(pre.textContent).toContain('name:')
39-
expect(pre.textContent).toContain('effect:')
39+
expect(pre.textContent).toContain('policy_type:')
4040
})
4141

4242
it('shows valid JSON when JSON toggle is clicked', () => {
43-
renderCodeView({ name: 'test-policy', effect: 'permit' })
43+
renderCodeView({ name: 'test-policy', policy_type: 'row_filter' })
4444
fireEvent.click(screen.getByText('View as code'))
4545
fireEvent.click(screen.getByText('JSON'))
4646
const pre = document.querySelector('pre')!
4747
expect(() => JSON.parse(pre.textContent!)).not.toThrow()
4848
const parsed = JSON.parse(pre.textContent!)
4949
expect(parsed.name).toBe('test-policy')
50-
expect(parsed.effect).toBe('permit')
50+
expect(parsed.policy_type).toBe('row_filter')
5151
})
5252

5353
it('copy button calls navigator.clipboard.writeText', () => {
@@ -57,30 +57,28 @@ describe('PolicyCodeView', () => {
5757
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1)
5858
})
5959

60-
it('code includes policy name, effect, and version', () => {
61-
renderCodeView({ name: 'my-policy', effect: 'deny', version: 3 })
60+
it('code includes policy name, policy_type, and version', () => {
61+
renderCodeView({ name: 'my-policy', policy_type: 'column_deny', version: 3 })
6262
fireEvent.click(screen.getByText('View as code'))
6363
fireEvent.click(screen.getByText('JSON'))
6464
const parsed = JSON.parse(document.querySelector('pre')!.textContent!)
6565
expect(parsed.name).toBe('my-policy')
66-
expect(parsed.effect).toBe('deny')
66+
expect(parsed.policy_type).toBe('column_deny')
6767
expect(parsed.version).toBe(3)
6868
})
6969

70-
it('obligations show flattened definition fields', () => {
71-
const obl = makeObligation({
72-
id: 'obl-1',
73-
obligation_type: 'row_filter',
74-
definition: { filter: 'tenant_id = 1' },
70+
it('code includes targets and definition', () => {
71+
const policy = makePolicy({
72+
policy_type: 'row_filter',
73+
targets: [{ schemas: ['public'], tables: ['orders'] }],
74+
definition: { filter_expression: 'tenant = 1' },
7575
})
76-
const policy = makePolicy({ obligations: [obl] })
7776
renderWithProviders(<PolicyCodeView policy={policy} assignments={[]} />)
7877
fireEvent.click(screen.getByText('View as code'))
7978
fireEvent.click(screen.getByText('JSON'))
8079
const parsed = JSON.parse(document.querySelector('pre')!.textContent!)
81-
expect(parsed.obligations).toHaveLength(1)
82-
expect(parsed.obligations[0].type).toBe('row_filter')
83-
expect(parsed.obligations[0].filter).toBe('tenant_id = 1')
80+
expect(parsed.targets).toHaveLength(1)
81+
expect(parsed.definition.filter_expression).toBe('tenant = 1')
8482
})
8583

8684
it('assignments show datasource and user names', () => {

admin-ui/src/components/PolicyCodeView.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,12 @@ function buildCodeObject(policy: PolicyResponse, assignments: PolicyAssignmentRe
1010
return {
1111
id: policy.id,
1212
name: policy.name,
13-
effect: policy.effect,
13+
policy_type: policy.policy_type,
1414
description: policy.description ?? null,
1515
is_enabled: policy.is_enabled,
1616
version: policy.version,
17-
obligations: (policy.obligations ?? []).map((o) => ({
18-
id: o.id,
19-
type: o.obligation_type,
20-
...o.definition,
21-
})),
17+
targets: policy.targets,
18+
definition: policy.definition ?? null,
2219
assignments: assignments.map((a) => ({
2320
id: a.id,
2421
datasource_id: a.data_source_id,

admin-ui/src/components/PolicyForm.test.tsx

Lines changed: 72 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -15,120 +15,110 @@ function renderForm(props: Partial<Parameters<typeof PolicyForm>[0]> = {}) {
1515
)
1616
}
1717

18-
describe('PolicyForm — deny + column_mask validation', () => {
19-
it('column_mask option is visible when effect is permit', async () => {
18+
describe('PolicyForm — policy type selector', () => {
19+
it('renders all 5 policy type options', () => {
2020
renderForm()
21-
// Add an obligation — effect defaults to permit
22-
await userEvent.click(screen.getByText('+ Add obligation'))
23-
const comboboxes = screen.getAllByRole('combobox')
24-
// The obligation type select is the last combobox
25-
const obligationTypeSelect = comboboxes[comboboxes.length - 1]
26-
const options = Array.from(obligationTypeSelect.querySelectorAll('option')).map(o => o.value)
21+
const select = screen.getAllByRole('combobox')[0]
22+
const options = Array.from(select.querySelectorAll('option')).map((o) => o.value)
23+
expect(options).toContain('row_filter')
2724
expect(options).toContain('column_mask')
25+
expect(options).toContain('column_allow')
26+
expect(options).toContain('column_deny')
27+
expect(options).toContain('table_deny')
2828
})
2929

30-
it('column_mask option is hidden when effect is deny', async () => {
30+
it('shows deny note when column_deny is selected', async () => {
3131
renderForm()
32-
// Switch effect to deny
33-
const effectSelect = screen.getAllByRole('combobox')[0]
34-
await userEvent.selectOptions(effectSelect, 'deny')
35-
// Add an obligation
36-
await userEvent.click(screen.getByText('+ Add obligation'))
37-
const typeSelects = screen.getAllByRole('combobox')
38-
// The obligation type select is the last combobox
39-
const obligationTypeSelect = typeSelects[typeSelects.length - 1]
40-
const options = Array.from(obligationTypeSelect.querySelectorAll('option')).map(o => o.value)
41-
expect(options).not.toContain('column_mask')
32+
const select = screen.getAllByRole('combobox')[0]
33+
await userEvent.selectOptions(select, 'column_deny')
34+
expect(screen.getByText(/Deny policies short-circuit or strip columns/i)).toBeTruthy()
4235
})
4336

44-
it('switching effect to deny removes existing column_mask obligations', async () => {
37+
it('shows filter expression field when row_filter is selected', async () => {
4538
renderForm()
46-
// Add a column_mask obligation while effect is permit
47-
await userEvent.click(screen.getByText('+ Add obligation'))
48-
const typeSelects = screen.getAllByRole('combobox')
49-
const obligationTypeSelect = typeSelects[typeSelects.length - 1]
50-
await userEvent.selectOptions(obligationTypeSelect, 'column_mask')
51-
// Verify the column_mask obligation is shown
52-
expect(screen.getByText('Obligation 1')).toBeTruthy()
53-
// Switch effect to deny
54-
const effectSelect = screen.getAllByRole('combobox')[0]
55-
await userEvent.selectOptions(effectSelect, 'deny')
56-
// The column_mask obligation should have been removed
57-
expect(screen.queryByText('Obligation 1')).toBeNull()
39+
const select = screen.getAllByRole('combobox')[0]
40+
await userEvent.selectOptions(select, 'row_filter')
41+
expect(screen.getByText(/Filter Expression/i)).toBeTruthy()
42+
expect(screen.queryByText(/Mask Expression/i)).toBeNull()
5843
})
5944

60-
it('shows a note about column masking when effect is deny', async () => {
45+
it('shows mask expression field when column_mask is selected', async () => {
6146
renderForm()
62-
const effectSelect = screen.getAllByRole('combobox')[0]
63-
await userEvent.selectOptions(effectSelect, 'deny')
64-
expect(screen.getByText(/Column masking is not available on deny policies/i)).toBeTruthy()
47+
const select = screen.getAllByRole('combobox')[0]
48+
await userEvent.selectOptions(select, 'column_mask')
49+
expect(screen.getByText(/Mask Expression/i)).toBeTruthy()
50+
expect(screen.queryByText(/Filter Expression/i)).toBeNull()
51+
})
52+
53+
it('hides definition fields when table_deny is selected', async () => {
54+
renderForm()
55+
const select = screen.getAllByRole('combobox')[0]
56+
await userEvent.selectOptions(select, 'table_deny')
57+
expect(screen.queryByText(/Filter Expression/i)).toBeNull()
58+
expect(screen.queryByText(/Mask Expression/i)).toBeNull()
6559
})
6660
})
6761

68-
describe('PolicyForm — object_access obligation type', () => {
69-
it('object_access option is available in the type selector', async () => {
62+
describe('PolicyForm — targets', () => {
63+
it('starts with one target entry', () => {
7064
renderForm()
71-
await userEvent.click(screen.getByText('+ Add obligation'))
72-
const typeSelects = screen.getAllByRole('combobox')
73-
const obligationTypeSelect = typeSelects[typeSelects.length - 1]
74-
const options = Array.from(obligationTypeSelect.querySelectorAll('option')).map(o => o.value)
75-
expect(options).toContain('object_access')
65+
expect(screen.getByText('Target 1')).toBeTruthy()
7666
})
7767

78-
it('object_access is available on deny policies', async () => {
68+
it('adds a target when "+ Add target" is clicked', async () => {
7969
renderForm()
80-
const effectSelect = screen.getAllByRole('combobox')[0]
81-
await userEvent.selectOptions(effectSelect, 'deny')
82-
await userEvent.click(screen.getByText('+ Add obligation'))
83-
const typeSelects = screen.getAllByRole('combobox')
84-
const obligationTypeSelect = typeSelects[typeSelects.length - 1]
85-
const options = Array.from(obligationTypeSelect.querySelectorAll('option')).map(o => o.value)
86-
expect(options).toContain('object_access')
70+
await userEvent.click(screen.getByText('+ Add target'))
71+
expect(screen.getByText('Target 2')).toBeTruthy()
8772
})
8873

89-
it('object_access shows schema field and optional table field', async () => {
74+
it('removes a target when Remove is clicked', async () => {
9075
renderForm()
91-
await userEvent.click(screen.getByText('+ Add obligation'))
92-
const typeSelects = screen.getAllByRole('combobox')
93-
const obligationTypeSelect = typeSelects[typeSelects.length - 1]
94-
await userEvent.selectOptions(obligationTypeSelect, 'object_access')
95-
// Should show the hint text for object_access
96-
expect(screen.getByText(/Hides the entire schema/i)).toBeTruthy()
97-
// Table field should have "leave blank" placeholder hint
98-
expect(document.body.textContent).toMatch(/optional/i)
76+
await userEvent.click(screen.getByText('+ Add target'))
77+
expect(screen.getByText('Target 2')).toBeTruthy()
78+
const removeButtons = screen.getAllByText('Remove')
79+
await userEvent.click(removeButtons[0])
80+
expect(screen.queryByText('Target 2')).toBeNull()
81+
})
82+
83+
it('shows columns field for column_allow', async () => {
84+
renderForm()
85+
const select = screen.getAllByRole('combobox')[0]
86+
await userEvent.selectOptions(select, 'column_allow')
87+
expect(screen.getByText('Columns')).toBeTruthy()
88+
})
89+
90+
it('hides columns field for row_filter', async () => {
91+
renderForm()
92+
const select = screen.getAllByRole('combobox')[0]
93+
await userEvent.selectOptions(select, 'row_filter')
94+
expect(screen.queryByText('Columns')).toBeNull()
95+
})
96+
97+
it('hides columns field for table_deny', async () => {
98+
renderForm()
99+
const select = screen.getAllByRole('combobox')[0]
100+
await userEvent.selectOptions(select, 'table_deny')
101+
expect(screen.queryByText('Columns')).toBeNull()
99102
})
100103
})
101104

102-
describe('PolicyForm — submits object_access obligation correctly', () => {
103-
it('omits table field when blank for schema-level deny', async () => {
105+
describe('PolicyForm — submission', () => {
106+
it('calls onSubmit with policy_type and targets', async () => {
104107
const onSubmit = vi.fn().mockResolvedValue(undefined)
105108
renderForm({ onSubmit })
106109

107-
// Add object_access obligation
108-
await userEvent.click(screen.getByText('+ Add obligation'))
109-
const typeSelects = screen.getAllByRole('combobox')
110-
await userEvent.selectOptions(typeSelects[typeSelects.length - 1], 'object_access')
111-
112-
// Set schema
113-
const textboxes = screen.getAllByRole('textbox')
114-
// Find the schema input (has placeholder 'analytics')
115-
const schemaInput = textboxes.find(i => (i as HTMLInputElement).placeholder === 'analytics')
116-
if (schemaInput) {
117-
await userEvent.clear(schemaInput)
118-
await userEvent.type(schemaInput, 'analytics')
119-
}
110+
// Name is required — fill it in
111+
const nameInput = screen.getAllByRole('textbox')[0]
112+
await userEvent.clear(nameInput)
113+
await userEvent.type(nameInput, 'my-policy')
120114

121-
// Submit
122115
fireEvent.submit(document.querySelector('form')!)
123-
// Wait for onSubmit to be called
124-
await new Promise(r => setTimeout(r, 0))
116+
await new Promise((r) => setTimeout(r, 0))
125117

126118
if (onSubmit.mock.calls.length > 0) {
127119
const values = onSubmit.mock.calls[0][0]
128-
const obl = values.obligations[0]
129-
expect(obl.obligation_type).toBe('object_access')
130-
expect(obl.definition.action).toBe('deny')
131-
expect(obl.definition.table).toBeUndefined()
120+
expect(values.policy_type).toBe('row_filter')
121+
expect(Array.isArray(values.targets)).toBe(true)
132122
}
133123
})
134124
})

0 commit comments

Comments
 (0)