Skip to content

Commit ce80e13

Browse files
Add list value type for ABAC user attributes
Support array-of-strings attributes (max 100 elements) for use with IN clauses in filter expressions. List attributes expand into multiple comma-separated placeholders in mangle_vars(), with empty lists producing a NULL sentinel. Includes full-stack changes: proxy validation, template variable expansion, decision function context, admin UI with tag/chip input and multi-select checkboxes, AttributeDefinitionForm extraction, and PolicyForm validation improvements.
1 parent 90cd42c commit ce80e13

21 files changed

Lines changed: 1572 additions & 325 deletions

admin-ui/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ React 19, Vite 7, Tailwind 4, TanStack Query 5, react-router-dom 7, Vitest 4, @t
3030
- `src/api/attributeDefinitions.ts` — API client: `listAttributeDefinitions`, `getAttributeDefinition`, `createAttributeDefinition`, `updateAttributeDefinition`, `deleteAttributeDefinition`
3131
- `src/types/attributeDefinition.ts` — TypeScript interfaces (`AttributeDefinition`, `CreateAttributeDefinitionPayload`, `UpdateAttributeDefinitionPayload`, `ValueType`, `EntityType`)
3232
- `src/pages/AttributeDefinitionsPage.tsx` — List attribute definitions with entity type filter, force-delete support
33-
- `src/pages/AttributeDefinitionEditPage.tsx` — Create/edit attribute definitions (exports `AttributeDefinitionCreatePage` and `AttributeDefinitionEditPage`)
34-
- `src/components/UserAttributeEditor.tsx` — Inline editor for user attributes on UserEditPage. Loads attribute definitions to show type-appropriate inputs (text, number, boolean toggle, enum dropdown). Shows `{user.KEY}` syntax hint per attribute.
33+
- `src/pages/AttributeDefinitionEditPage.tsx` — Create/edit attribute definitions (exports `AttributeDefinitionCreatePage` and `AttributeDefinitionEditPage`). Uses `AttributeDefinitionForm` component with standard card wrapper + back button layout.
34+
- `src/components/AttributeDefinitionForm.tsx` — Reusable form for attribute definition create/edit (matches `RoleForm`/`DataSourceForm` pattern). Supports value types: string, integer, boolean, list.
35+
- `src/components/UserAttributeEditor.tsx` — Inline editor for user attributes on UserEditPage. Loads attribute definitions to show type-appropriate inputs (text, number, boolean toggle, enum dropdown, tag/chip input for lists, multi-select checkboxes for lists with allowed values). Shows `{user.KEY}` syntax hint per attribute.
3536
- `src/test/test-utils.tsx``renderWithProviders` (QueryClient + AuthProvider + MemoryRouter)
3637
- `src/test/factories.ts``makeUser`, `makeDataSource`, `makeDataSourceType`, `makeDiscoveredSchema/Table/Column`, `makeDecisionFunction`, `makePolicy`, `makePolicyAssignment`
3738

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { type FormEvent, useState, useEffect } from 'react'
2+
import type {
3+
ValueType,
4+
EntityType,
5+
AttributeDefinition,
6+
} from '../types/attributeDefinition'
7+
8+
export interface AttributeDefinitionFormValues {
9+
key: string
10+
entity_type: EntityType
11+
display_name: string
12+
value_type: ValueType
13+
default_value: string
14+
allowed_values: string[] | undefined
15+
description: string
16+
}
17+
18+
interface Props {
19+
mode: 'create' | 'edit'
20+
initial?: AttributeDefinition
21+
onSubmit: (values: AttributeDefinitionFormValues) => Promise<void>
22+
onCancel: () => void
23+
submitLabel: string
24+
isSubmitting: boolean
25+
error?: string | null
26+
}
27+
28+
const VALUE_TYPES: ValueType[] = ['string', 'integer', 'boolean', 'list']
29+
const ENTITY_TYPES: EntityType[] = ['user', 'table', 'column']
30+
31+
const inputCls =
32+
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
33+
34+
export function AttributeDefinitionForm({
35+
mode,
36+
initial,
37+
onSubmit,
38+
onCancel,
39+
submitLabel,
40+
isSubmitting,
41+
error,
42+
}: Props) {
43+
const [key, setKey] = useState('')
44+
const [entityType, setEntityType] = useState<EntityType>('user')
45+
const [displayName, setDisplayName] = useState('')
46+
const [valueType, setValueType] = useState<ValueType>('string')
47+
const [defaultValue, setDefaultValue] = useState('')
48+
const [allowedValuesText, setAllowedValuesText] = useState('')
49+
const [description, setDescription] = useState('')
50+
51+
useEffect(() => {
52+
if (initial) {
53+
setKey(initial.key)
54+
setEntityType(initial.entity_type)
55+
setDisplayName(initial.display_name)
56+
setValueType(initial.value_type)
57+
setDefaultValue(initial.default_value ?? '')
58+
setAllowedValuesText(initial.allowed_values?.join(', ') ?? '')
59+
setDescription(initial.description ?? '')
60+
}
61+
}, [initial])
62+
63+
async function handleSubmit(e: FormEvent) {
64+
e.preventDefault()
65+
const allowedValues = allowedValuesText.trim()
66+
? allowedValuesText
67+
.split(',')
68+
.map((v) => v.trim())
69+
.filter(Boolean)
70+
: undefined
71+
72+
await onSubmit({
73+
key,
74+
entity_type: entityType,
75+
display_name: displayName,
76+
value_type: valueType,
77+
default_value: defaultValue,
78+
allowed_values: allowedValues,
79+
description,
80+
})
81+
}
82+
83+
return (
84+
<form onSubmit={handleSubmit} className="space-y-5 max-w-lg">
85+
{mode === 'create' && (
86+
<>
87+
<div>
88+
<label className="block text-sm font-medium text-gray-700 mb-1">
89+
Key <span className="text-red-500">*</span>
90+
</label>
91+
<input
92+
type="text"
93+
value={key}
94+
onChange={(e) => setKey(e.target.value)}
95+
placeholder="e.g., region, clearance_level"
96+
required
97+
pattern="[a-zA-Z][a-zA-Z0-9_]*"
98+
maxLength={64}
99+
className={`${inputCls} font-mono`}
100+
/>
101+
<p className="text-xs text-gray-400 mt-1">
102+
Letters, digits, underscores. Used as {'{'}user.key{'}'} in expressions.
103+
</p>
104+
</div>
105+
<div>
106+
<label className="block text-sm font-medium text-gray-700 mb-1">
107+
Entity type <span className="text-red-500">*</span>
108+
</label>
109+
<select
110+
value={entityType}
111+
onChange={(e) => setEntityType(e.target.value as EntityType)}
112+
className={inputCls}
113+
>
114+
{ENTITY_TYPES.map((t) => (
115+
<option key={t} value={t}>
116+
{t}
117+
</option>
118+
))}
119+
</select>
120+
</div>
121+
</>
122+
)}
123+
124+
<div>
125+
<label className="block text-sm font-medium text-gray-700 mb-1">
126+
Display name <span className="text-red-500">*</span>
127+
</label>
128+
<input
129+
type="text"
130+
value={displayName}
131+
onChange={(e) => setDisplayName(e.target.value)}
132+
placeholder="e.g., Region, Clearance Level"
133+
required
134+
className={inputCls}
135+
/>
136+
</div>
137+
138+
<div>
139+
<label className="block text-sm font-medium text-gray-700 mb-1">
140+
Value type <span className="text-red-500">*</span>
141+
</label>
142+
<select
143+
value={valueType}
144+
onChange={(e) => setValueType(e.target.value as ValueType)}
145+
className={inputCls}
146+
>
147+
{VALUE_TYPES.map((t) => (
148+
<option key={t} value={t}>
149+
{t === 'list' ? 'list (multiple strings)' : t}
150+
</option>
151+
))}
152+
</select>
153+
{valueType === 'list' && (
154+
<p className="text-xs text-gray-400 mt-1">
155+
List attributes store multiple string values. Use with <code className="bg-gray-100 px-1 rounded">IN {'{'}user.key{'}'}</code> in filter expressions.
156+
</p>
157+
)}
158+
</div>
159+
160+
<div>
161+
<label className="block text-sm font-medium text-gray-700 mb-1">
162+
Allowed values
163+
</label>
164+
<input
165+
type="text"
166+
value={allowedValuesText}
167+
onChange={(e) => setAllowedValuesText(e.target.value)}
168+
placeholder="Comma-separated, e.g., us-east, eu-west, ap-south"
169+
className={inputCls}
170+
/>
171+
<p className="text-xs text-gray-400 mt-1">
172+
{valueType === 'list'
173+
? 'Constrains which strings can appear as elements in the list.'
174+
: 'Leave empty to allow any value of the selected type.'}
175+
</p>
176+
</div>
177+
178+
<div>
179+
<label className="block text-sm font-medium text-gray-700 mb-1">
180+
Default value
181+
</label>
182+
<input
183+
type="text"
184+
value={defaultValue}
185+
onChange={(e) => setDefaultValue(e.target.value)}
186+
placeholder={valueType === 'list' ? 'JSON array, e.g., ["default"]' : 'Optional'}
187+
className={inputCls}
188+
/>
189+
</div>
190+
191+
<div>
192+
<label className="block text-sm font-medium text-gray-700 mb-1">
193+
Description <span className="text-gray-400 font-normal">(optional)</span>
194+
</label>
195+
<textarea
196+
value={description}
197+
onChange={(e) => setDescription(e.target.value)}
198+
placeholder="Optional description"
199+
rows={2}
200+
className={inputCls}
201+
/>
202+
</div>
203+
204+
{error && (
205+
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
206+
{error}
207+
</div>
208+
)}
209+
210+
<div className="flex gap-3 pt-2">
211+
<button
212+
type="submit"
213+
disabled={isSubmitting}
214+
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-medium rounded-lg px-5 py-2 text-sm transition-colors"
215+
>
216+
{isSubmitting ? 'Saving...' : submitLabel}
217+
</button>
218+
<button
219+
type="button"
220+
onClick={onCancel}
221+
className="text-gray-600 hover:text-gray-900 font-medium text-sm px-3 py-2 transition-colors"
222+
>
223+
Cancel
224+
</button>
225+
</div>
226+
</form>
227+
)
228+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const mockUpdateDecisionFunction = vi.fn()
2222
const mockTestDecisionFn = vi.fn()
2323
const mockGetDecisionFunction = vi.fn()
2424

25+
vi.mock('../api/attributeDefinitions', () => ({
26+
listAttributeDefinitions: vi.fn().mockResolvedValue({ data: [], total: 0, page: 1, page_size: 200 }),
27+
}))
28+
2529
vi.mock('../api/decisionFunctions', () => ({
2630
createDecisionFunction: (...args: unknown[]) => mockCreateDecisionFunction(...args),
2731
updateDecisionFunction: (...args: unknown[]) => mockUpdateDecisionFunction(...args),
@@ -338,6 +342,40 @@ describe('DecisionFunctionModal — error handling', () => {
338342
})
339343
})
340344

345+
it('blocks save when config JSON is invalid', async () => {
346+
renderModal()
347+
348+
const editors = screen.getAllByTestId('codemirror')
349+
// Set valid code
350+
fireEvent.change(editors[0], { target: { value: VALID_FN_TRUE } })
351+
// Set invalid config JSON
352+
fireEvent.change(editors[1], { target: { value: '{bad json' } })
353+
354+
await userEvent.click(screen.getByText('Create Function'))
355+
356+
await waitFor(() => {
357+
expect(screen.getByText('Invalid JSON in Configuration')).toBeTruthy()
358+
})
359+
expect(mockCreateDecisionFunction).not.toHaveBeenCalled()
360+
})
361+
362+
it('allows save when config is empty (defaults to {})', async () => {
363+
const created = makeDecisionFunction()
364+
mockCreateDecisionFunction.mockResolvedValue(created)
365+
const onSaved = vi.fn()
366+
renderModal({ onSaved })
367+
368+
const editors = screen.getAllByTestId('codemirror')
369+
fireEvent.change(editors[0], { target: { value: VALID_FN_TRUE } })
370+
// Leave config editor empty (default)
371+
372+
await userEvent.click(screen.getByText('Create Function'))
373+
374+
await waitFor(() => {
375+
expect(mockCreateDecisionFunction).toHaveBeenCalledTimes(1)
376+
})
377+
})
378+
341379
it('disables Save button while saving', async () => {
342380
mockCreateDecisionFunction.mockImplementation(() => new Promise(() => {})) // never resolves
343381
renderModal()

0 commit comments

Comments
 (0)