Skip to content

Commit b8c4fca

Browse files
Flatten user attributes in decision context and add expression editor
BREAKING: Decision function context changes from `ctx.session.user.attributes.KEY` to `ctx.session.user.KEY`. Existing decision functions referencing the old path must be updated. - Flatten custom attributes as first-class fields on `ctx.session.user` object, with built-in fields (id, username, tenant, roles) always winning on collision - Add ExpressionEditor component with CodeMirror, {user.*} template autocomplete, and server-side expression validation via new POST /policies/validate-expression - Derive reserved attribute keys from ORM columns via LazyLock instead of hardcoded list - Replace plain text inputs with ExpressionEditor in PolicyForm for filter/mask expressions - Update DecisionFunctionModal autocomplete to match flattened context - Mark Conditional Policies as resolved in roadmap (covered by CASE WHEN + decision fns) - Add comprehensive ABAC expression patterns and conditional policy examples to docs
1 parent 7c831c1 commit b8c4fca

15 files changed

Lines changed: 945 additions & 165 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ All policy endpoints require admin (`is_admin = true`).
449449
| GET | `/policies/{id}` | Get policy + assignment count |
450450
| PUT | `/policies/{id}` | Update policy (requires `version` for optimistic concurrency → 409 on conflict) |
451451
| DELETE | `/policies/{id}` | Delete policy (cascades) |
452+
| POST | `/policies/validate-expression` | Validate a filter/mask expression `{ expression, is_mask }``{ valid, error? }` |
452453
| GET | `/policies/export` | Export all policies as YAML |
453454
| POST | `/policies/import` | Import YAML (`?dry_run=true` to preview) |
454455
| GET | `/datasources/{id}/policies` | List policy assignments for datasource |

admin-ui/src/api/policies.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,14 @@ export async function removeAssignment(
6565
): Promise<void> {
6666
await client.delete(`/datasources/${datasourceId}/policies/${assignmentId}`)
6767
}
68+
69+
export async function validateExpression(
70+
expression: string,
71+
isMask: boolean,
72+
): Promise<{ valid: boolean; error?: string }> {
73+
const { data } = await client.post<{ valid: boolean; error?: string }>(
74+
'/policies/validate-expression',
75+
{ expression, is_mask: isMask },
76+
)
77+
return data
78+
}

admin-ui/src/components/DecisionFunctionModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function buildCtxCompletions(evaluateContext: EvaluateContext, configStr: string
5151
{ label: 'ctx.session.user.username', type: 'variable' as const },
5252
{ label: 'ctx.session.user.tenant', type: 'variable' as const },
5353
{ label: 'ctx.session.user.roles', type: 'variable' as const, detail: 'string[]' },
54-
{ label: 'ctx.session.user.attributes', type: 'variable' as const, detail: 'object' },
54+
{ label: 'ctx.session.time.now', type: 'variable' as const, detail: 'ISO 8601 timestamp' },
5555
{ label: 'ctx.session.time.hour', type: 'variable' as const, detail: '0-23' },
5656
{ label: 'ctx.session.time.day_of_week', type: 'variable' as const, detail: 'Monday-Sunday' },
5757
{ label: 'ctx.session.datasource.name', type: 'variable' as const },
@@ -62,7 +62,7 @@ function buildCtxCompletions(evaluateContext: EvaluateContext, configStr: string
6262
for (const def of attrDefs) {
6363
const detail = def.value_type === 'list' ? 'string[]' : def.value_type
6464
items.push({
65-
label: `ctx.session.user.attributes.${def.key}`,
65+
label: `ctx.session.user.${def.key}`,
6666
type: 'variable' as const,
6767
detail,
6868
})
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { screen, fireEvent, waitFor } from '@testing-library/react'
3+
import { ExpressionEditor } from './ExpressionEditor'
4+
import { renderWithProviders } from '../test/test-utils'
5+
6+
// Mock CodeMirror as a simple textarea (same pattern as PolicyForm tests)
7+
vi.mock('@uiw/react-codemirror', () => ({
8+
default: ({
9+
value,
10+
onChange,
11+
placeholder,
12+
}: {
13+
value: string
14+
onChange?: (v: string) => void
15+
placeholder?: string
16+
}) => (
17+
<textarea
18+
data-testid="codemirror"
19+
value={value}
20+
onChange={(e) => onChange?.(e.target.value)}
21+
placeholder={placeholder}
22+
/>
23+
),
24+
}))
25+
26+
describe('ExpressionEditor', () => {
27+
it('renders with placeholder', () => {
28+
renderWithProviders(
29+
<ExpressionEditor
30+
value=""
31+
onChange={() => {}}
32+
placeholder="organization_id = {user.tenant}"
33+
templateItems={[]}
34+
/>,
35+
)
36+
expect(screen.getByPlaceholderText('organization_id = {user.tenant}')).toBeTruthy()
37+
})
38+
39+
it('renders with initial value', () => {
40+
renderWithProviders(
41+
<ExpressionEditor
42+
value="tenant = {user.tenant}"
43+
onChange={() => {}}
44+
templateItems={[]}
45+
/>,
46+
)
47+
expect(screen.getByDisplayValue('tenant = {user.tenant}')).toBeTruthy()
48+
})
49+
50+
it('calls onChange when value changes', () => {
51+
const onChange = vi.fn()
52+
renderWithProviders(
53+
<ExpressionEditor value="" onChange={onChange} templateItems={[]} />,
54+
)
55+
fireEvent.change(screen.getByTestId('codemirror'), {
56+
target: { value: 'region = {user.region}' },
57+
})
58+
expect(onChange).toHaveBeenCalledWith('region = {user.region}')
59+
})
60+
61+
it('shows Check button when onValidate is provided', () => {
62+
renderWithProviders(
63+
<ExpressionEditor
64+
value="test"
65+
onChange={() => {}}
66+
templateItems={[]}
67+
onValidate={vi.fn().mockResolvedValue({ valid: true })}
68+
/>,
69+
)
70+
expect(screen.getByTitle('Validate expression syntax')).toBeTruthy()
71+
})
72+
73+
it('does not show Check button when onValidate is not provided', () => {
74+
renderWithProviders(
75+
<ExpressionEditor value="test" onChange={() => {}} templateItems={[]} />,
76+
)
77+
expect(screen.queryByTitle('Validate expression syntax')).toBeNull()
78+
})
79+
80+
it('shows green check on valid expression', async () => {
81+
const onValidate = vi.fn().mockResolvedValue({ valid: true })
82+
renderWithProviders(
83+
<ExpressionEditor
84+
value="region = {user.region}"
85+
onChange={() => {}}
86+
templateItems={[]}
87+
onValidate={onValidate}
88+
/>,
89+
)
90+
fireEvent.click(screen.getByTitle('Validate expression syntax'))
91+
await waitFor(() => {
92+
expect(onValidate).toHaveBeenCalledWith('region = {user.region}')
93+
expect(screen.getByText('✓')).toBeTruthy()
94+
})
95+
})
96+
97+
it('shows red X and error on invalid expression', async () => {
98+
const onValidate = vi
99+
.fn()
100+
.mockResolvedValue({ valid: false, error: 'Unsupported syntax: EXTRACT' })
101+
renderWithProviders(
102+
<ExpressionEditor
103+
value="EXTRACT(HOUR FROM col)"
104+
onChange={() => {}}
105+
templateItems={[]}
106+
onValidate={onValidate}
107+
/>,
108+
)
109+
fireEvent.click(screen.getByTitle('Validate expression syntax'))
110+
await waitFor(() => {
111+
expect(screen.getByText('✗')).toBeTruthy()
112+
expect(screen.getByText('Unsupported syntax: EXTRACT')).toBeTruthy()
113+
})
114+
})
115+
116+
it('resets validation state when expression changes', async () => {
117+
const onValidate = vi
118+
.fn()
119+
.mockResolvedValue({ valid: false, error: 'bad' })
120+
const onChange = vi.fn()
121+
renderWithProviders(
122+
<ExpressionEditor
123+
value="bad expr"
124+
onChange={onChange}
125+
templateItems={[]}
126+
onValidate={onValidate}
127+
/>,
128+
)
129+
// Validate first
130+
fireEvent.click(screen.getByTitle('Validate expression syntax'))
131+
await waitFor(() => expect(screen.getByText('bad')).toBeTruthy())
132+
133+
// Change expression — validation error should clear
134+
fireEvent.change(screen.getByTestId('codemirror'), {
135+
target: { value: 'new expr' },
136+
})
137+
expect(screen.queryByText('bad')).toBeNull()
138+
})
139+
140+
it('disables Check button when expression is empty', () => {
141+
renderWithProviders(
142+
<ExpressionEditor
143+
value=""
144+
onChange={() => {}}
145+
templateItems={[]}
146+
onValidate={vi.fn()}
147+
/>,
148+
)
149+
const btn = screen.getByTitle('Validate expression syntax')
150+
expect(btn).toHaveProperty('disabled', true)
151+
})
152+
})
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { useState, useCallback, useMemo } from 'react'
2+
import CodeMirror from '@uiw/react-codemirror'
3+
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
4+
import { EditorView } from '@codemirror/view'
5+
6+
export interface TemplateItem {
7+
key: string
8+
valueType: string
9+
}
10+
11+
interface ExpressionEditorProps {
12+
value: string
13+
onChange: (value: string) => void
14+
placeholder?: string
15+
templateItems: TemplateItem[]
16+
onValidate?: (expression: string) => Promise<{ valid: boolean; error?: string }>
17+
}
18+
19+
const BUILTIN_VARS = [
20+
{ label: '{user.tenant}', detail: 'string', apply: '{user.tenant}' },
21+
{ label: '{user.username}', detail: 'string', apply: '{user.username}' },
22+
{ label: '{user.id}', detail: 'string', apply: '{user.id}' },
23+
]
24+
25+
function templateCompletionSource(items: TemplateItem[]) {
26+
return (context: CompletionContext): CompletionResult | null => {
27+
// Match {user. followed by optional word chars and optional closing brace
28+
const match = context.matchBefore(/\{user\.[\w]*\}?/)
29+
if (!match) return null
30+
31+
const custom = items.map((a) => ({
32+
label: `{user.${a.key}}`,
33+
detail: a.valueType === 'list' ? 'list (use with IN)' : a.valueType,
34+
apply: `{user.${a.key}}`,
35+
}))
36+
37+
return {
38+
from: match.from,
39+
options: [...BUILTIN_VARS, ...custom],
40+
filter: true,
41+
}
42+
}
43+
}
44+
45+
const compactTheme = EditorView.theme({
46+
'&': { fontSize: '14px' },
47+
'.cm-editor': { maxHeight: '120px', overflow: 'auto' },
48+
'.cm-content': { padding: '6px 10px', fontFamily: 'ui-monospace, monospace' },
49+
'.cm-line': { lineHeight: '1.5' },
50+
'.cm-placeholder': { color: '#9ca3af' },
51+
'.cm-focused': { outline: 'none' },
52+
})
53+
54+
export function ExpressionEditor({
55+
value,
56+
onChange,
57+
placeholder,
58+
templateItems,
59+
onValidate,
60+
}: ExpressionEditorProps) {
61+
const [validationState, setValidationState] = useState<
62+
'idle' | 'loading' | 'valid' | 'invalid'
63+
>('idle')
64+
const [validationError, setValidationError] = useState<string>('')
65+
66+
const handleChange = useCallback(
67+
(val: string) => {
68+
onChange(val)
69+
// Reset validation state when expression changes
70+
if (validationState !== 'idle') {
71+
setValidationState('idle')
72+
setValidationError('')
73+
}
74+
},
75+
[onChange, validationState],
76+
)
77+
78+
const handleValidate = useCallback(async () => {
79+
if (!onValidate || !value.trim()) return
80+
setValidationState('loading')
81+
try {
82+
const result = await onValidate(value)
83+
if (result.valid) {
84+
setValidationState('valid')
85+
setValidationError('')
86+
} else {
87+
setValidationState('invalid')
88+
setValidationError(result.error ?? 'Invalid expression')
89+
}
90+
} catch {
91+
setValidationState('invalid')
92+
setValidationError('Validation request failed')
93+
}
94+
}, [onValidate, value])
95+
96+
const extensions = useMemo(
97+
() => [
98+
autocompletion({ override: [templateCompletionSource(templateItems)] }),
99+
compactTheme,
100+
],
101+
[templateItems],
102+
)
103+
104+
return (
105+
<div>
106+
<div className="flex items-center gap-2">
107+
<div
108+
className={`flex-1 border rounded-lg overflow-hidden ${
109+
validationState === 'invalid'
110+
? 'border-red-400'
111+
: validationState === 'valid'
112+
? 'border-green-400'
113+
: 'border-gray-300 focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500'
114+
}`}
115+
>
116+
<CodeMirror
117+
value={value}
118+
onChange={handleChange}
119+
placeholder={placeholder}
120+
basicSetup={{
121+
lineNumbers: false,
122+
foldGutter: false,
123+
highlightActiveLine: false,
124+
autocompletion: false,
125+
}}
126+
extensions={extensions}
127+
/>
128+
</div>
129+
{onValidate && (
130+
<button
131+
type="button"
132+
onClick={handleValidate}
133+
disabled={validationState === 'loading' || !value.trim()}
134+
className="shrink-0 px-2 py-1.5 text-xs font-medium rounded-md border border-gray-300 bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
135+
title="Validate expression syntax"
136+
>
137+
{validationState === 'loading' ? (
138+
<span className="inline-block w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
139+
) : validationState === 'valid' ? (
140+
<span className="text-green-600">&#10003;</span>
141+
) : validationState === 'invalid' ? (
142+
<span className="text-red-600">&#10007;</span>
143+
) : (
144+
'Check'
145+
)}
146+
</button>
147+
)}
148+
</div>
149+
{validationState === 'invalid' && validationError && (
150+
<p className="text-xs text-red-500 mt-1">{validationError}</p>
151+
)}
152+
</div>
153+
)
154+
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ vi.mock('../api/attributeDefinitions', () => ({
2727
listAttributeDefinitions: vi.fn().mockResolvedValue({ data: [], total: 0, page: 1, page_size: 200 }),
2828
}))
2929

30+
vi.mock('../api/policies', () => ({
31+
validateExpression: vi.fn().mockResolvedValue({ valid: true }),
32+
}))
33+
3034
vi.mock('../api/decisionFunctions', () => ({
3135
listDecisionFunctions: (...args: unknown[]) => mockListDecisionFunctions(...args),
3236
getDecisionFunction: (...args: unknown[]) => mockGetDecisionFunction(...args),
@@ -568,7 +572,9 @@ describe('PolicyForm — decision function validation', () => {
568572
// Modal should be open — fill code and save
569573
await waitFor(() => expect(screen.getByText('Create Function')).toBeTruthy())
570574
const editors = screen.getAllByTestId('codemirror')
571-
fireEvent.change(editors[0], { target: { value: 'function evaluate(ctx) { return { fire: true }; }' } })
575+
// editors[0] is the filter expression editor; modal JS editor follows it
576+
const jsEditor = editors.length > 1 ? editors[1] : editors[0]
577+
fireEvent.change(jsEditor, { target: { value: 'function evaluate(ctx) { return { fire: true }; }' } })
572578
await userEvent.click(screen.getByText('Create Function'))
573579

574580
// Wait for modal to close and function to be attached

0 commit comments

Comments
 (0)