Skip to content

Commit 7935b6c

Browse files
Add ABAC user attributes with schema-first definitions and typed template variables
Schema-first attribute system: attribute_definition table defines allowed keys with types (string/integer/boolean), entity type scoping, optional enum constraints, and reserved key protection. User attributes stored as JSON column on proxy_user with full-replace semantics and write-time validation. Key changes: - Attribute definitions CRUD API with force-delete cascade (SQLite/PostgreSQL) - Typed {user.KEY} template variables in filter/mask expressions (Utf8/Int64/Boolean) - User attributes in decision function context (ctx.session.user.attributes) - time.now (RFC 3339) added to decision context for time-windowed access - Save-time expression validation (validate_expression) prevents silent query-time failures - CASE WHEN support in expression parser (sql_ast_to_df_expr) - Shared Arc<WasmDecisionRuntime> singleton replaces per-use instantiation - Admin UI: attribute definition pages, user attribute editor with type-aware inputs - Security vectors 59-68 documented; rename permission-security-tests.md → security-vectors.md - 3 new migrations (052-054): attribute_definition table, unique index, user attributes column
1 parent 9f79967 commit 7935b6c

41 files changed

Lines changed: 3781 additions & 319 deletions

Some content is hidden

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

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Every feature plan MUST include a comprehensive test case inventory before imple
6464
| **Multi-entity interaction** | How do multiple instances interact? | Multiple roles, multiple datasources, overlapping policies, priority conflicts |
6565
| **Backward compatibility** | Does existing functionality still work? | Old API formats, migration of existing data, default values |
6666

67-
**Test naming convention:** Group tests by category with descriptive names. Map security-relevant tests to vector numbers in `docs/permission-security-tests.md`.
67+
**Test naming convention:** Group tests by category with descriptive names. Map security-relevant tests to vector numbers in `docs/security-vectors.md`.
6868

6969
### Security-First Thinking
7070
This is a data security product. Every feature that touches access control, policy resolution, or data visibility must be evaluated through a security lens:

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
111111
- **Admin UI — Policy Assignments** — assign/remove policies per datasource with optional user scope and priority
112112
- **Admin UI — Query Audit** — paginated audit log with original query, rewritten query, and applied policy snapshots
113113
- **Demo e-commerce schema**`scripts/demo_ecommerce/` with schema, seed script, and example policies
114-
- **Docs**`docs/permission-system.md` (user guide) and `docs/permission-security-tests.md` (security test plan)
114+
- **Docs**`docs/permission-system.md` (user guide) and `docs/security-vectors.md` (security test plan)
115115

116116
### Changed
117117
- **Arrow encoding** — migrated to `arrow-pg`; handler simplified; removed `arrow_conversion` and `sql_rewrite` modules

Cargo.lock

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

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ betweenrows/
150150
├── migration/src/ SeaORM migrations (41 total)
151151
├── docs/ User-facing documentation
152152
│ ├── permission-system.md Policy system user guide
153-
│ ├── permission-security-tests.md Security test plan
153+
│ ├── security-vectors.md Security attack vectors & test plan
154154
│ ├── permission-stories.md Detailed permission use cases
155155
│ └── roadmap.md Project roadmap and backlog
156156
├── scripts/demo_ecommerce/ Demo schema + seed data
@@ -333,7 +333,7 @@ QueryProxy enforces a two-layer access control model:
333333

334334
**Data plane** — controlled by two independent mechanisms:
335335
1. *Connection access*`data_source_access` entries. A user can connect to a datasource via direct assignment, role membership (including inherited roles), or all-user scope. Being an admin does **not** automatically grant data plane access.
336-
2. *Query policy*`PolicyHook` applies row filters, column masks, and column access controls per-query based on assigned policies (direct, role-based, or all-scoped). If the datasource `access_mode` is `"policy_required"`, tables with no matching permit policy return empty results.
336+
2. *Query policy*`PolicyHook` applies row filters, column masks, and column access controls per-query based on assigned policies (direct, role-based, or all-scoped). If the datasource `access_mode` is `"policy_required"`, tables with no matching permit policy return empty results. Policies can reference built-in identity fields (`{user.tenant}`, `{user.username}`, `{user.id}`) and custom user attributes (`{user.KEY}`) for attribute-based access control (ABAC). Optional decision functions (JavaScript/WASM) provide programmable policy gates.
337337

338338
See `docs/permission-system.md` for the full policy system user guide.
339339

@@ -455,6 +455,29 @@ All policy endpoints require admin (`is_admin = true`).
455455
| POST | `/datasources/{id}/policies` | Assign policy to datasource (scope: user/role/all) |
456456
| DELETE | `/datasources/{id}/policies/{assignment_id}` | Remove assignment |
457457

458+
### Decision Functions
459+
460+
| Method | Path | Description |
461+
|--------|------|-------------|
462+
| GET | `/decision-functions` | List decision functions (paginated) |
463+
| POST | `/decision-functions` | Create decision function |
464+
| GET | `/decision-functions/{id}` | Get decision function |
465+
| PUT | `/decision-functions/{id}` | Update decision function (optimistic concurrency) |
466+
| DELETE | `/decision-functions/{id}` | Delete decision function |
467+
| POST | `/decision-functions/{id}/test` | Test decision function with sample context |
468+
469+
### Attribute Definitions
470+
471+
| Method | Path | Description |
472+
|--------|------|-------------|
473+
| GET | `/attribute-definitions` | List definitions (`?entity_type=user` filter, paginated) |
474+
| POST | `/attribute-definitions` | Create attribute definition |
475+
| GET | `/attribute-definitions/{id}` | Get definition |
476+
| PUT | `/attribute-definitions/{id}` | Update definition |
477+
| DELETE | `/attribute-definitions/{id}` | Delete definition (`?force=true` to cascade-remove from entities) |
478+
479+
User attributes are set via `PUT /users/{id}` with an `attributes` field (full-replace semantics, validated against definitions). Attributes are available as `{user.KEY}` template variables in policy expressions and as `ctx.session.user.attributes` in decision functions.
480+
458481
### Audit Log
459482

460483
| Method | Path | Description |

admin-ui/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ React 19, Vite 7, Tailwind 4, TanStack Query 5, react-router-dom 7, Vitest 4, @t
2727
- `src/types/role.ts` — TypeScript interfaces for roles, members, audit entries
2828
- `src/types/decisionFunction.ts` — TypeScript interfaces (`DecisionFunctionResponse`, `DecisionFunctionSummary`, `CreateDecisionFunctionPayload`, `UpdateDecisionFunctionPayload`, `TestDecisionFnPayload`, `TestDecisionFnResponse`, `EvaluateContext`, `OnErrorBehavior`, `LogLevel`)
2929
- `src/api/decisionFunctions.ts` — API client: `listDecisionFunctions`, `getDecisionFunction`, `createDecisionFunction`, `updateDecisionFunction`, `deleteDecisionFunction`, `testDecisionFn`
30+
- `src/api/attributeDefinitions.ts` — API client: `listAttributeDefinitions`, `getAttributeDefinition`, `createAttributeDefinition`, `updateAttributeDefinition`, `deleteAttributeDefinition`
31+
- `src/types/attributeDefinition.ts` — TypeScript interfaces (`AttributeDefinition`, `CreateAttributeDefinitionPayload`, `UpdateAttributeDefinitionPayload`, `ValueType`, `EntityType`)
32+
- `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.
3035
- `src/test/test-utils.tsx``renderWithProviders` (QueryClient + AuthProvider + MemoryRouter)
3136
- `src/test/factories.ts``makeUser`, `makeDataSource`, `makeDataSourceType`, `makeDiscoveredSchema/Table/Column`, `makeDecisionFunction`, `makePolicy`, `makePolicyAssignment`
3237

admin-ui/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { RolesListPage } from './pages/RolesListPage'
1919
import { RoleCreatePage } from './pages/RoleCreatePage'
2020
import { RoleEditPage } from './pages/RoleEditPage'
2121
import { AdminAuditPage } from './pages/AdminAuditPage'
22+
import { AttributeDefinitionsPage } from './pages/AttributeDefinitionsPage'
23+
import { AttributeDefinitionCreatePage, AttributeDefinitionEditPage } from './pages/AttributeDefinitionEditPage'
2224

2325
const queryClient = new QueryClient({
2426
defaultOptions: {
@@ -49,6 +51,9 @@ export function App() {
4951
<Route path="roles" element={<RolesListPage />} />
5052
<Route path="roles/create" element={<RoleCreatePage />} />
5153
<Route path="roles/:id" element={<RoleEditPage />} />
54+
<Route path="attributes" element={<AttributeDefinitionsPage />} />
55+
<Route path="attributes/create" element={<AttributeDefinitionCreatePage />} />
56+
<Route path="attributes/:id/edit" element={<AttributeDefinitionEditPage />} />
5257
<Route path="policies" element={<PoliciesListPage />} />
5358
<Route path="policies/create" element={<PolicyCreatePage />} />
5459
<Route path="policies/:id/edit" element={<PolicyEditPage />} />
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { client } from './client'
2+
import type {
3+
AttributeDefinition,
4+
CreateAttributeDefinitionPayload,
5+
UpdateAttributeDefinitionPayload,
6+
} from '../types/attributeDefinition'
7+
import type { PaginatedResponse } from '../types/user'
8+
9+
export async function listAttributeDefinitions(params?: {
10+
entity_type?: string
11+
page?: number
12+
page_size?: number
13+
}): Promise<PaginatedResponse<AttributeDefinition>> {
14+
const { data } = await client.get<PaginatedResponse<AttributeDefinition>>(
15+
'/attribute-definitions',
16+
{ params },
17+
)
18+
return data
19+
}
20+
21+
export async function getAttributeDefinition(id: string): Promise<AttributeDefinition> {
22+
const { data } = await client.get<AttributeDefinition>(`/attribute-definitions/${id}`)
23+
return data
24+
}
25+
26+
export async function createAttributeDefinition(
27+
payload: CreateAttributeDefinitionPayload,
28+
): Promise<AttributeDefinition> {
29+
const { data } = await client.post<AttributeDefinition>('/attribute-definitions', payload)
30+
return data
31+
}
32+
33+
export async function updateAttributeDefinition(
34+
id: string,
35+
payload: UpdateAttributeDefinitionPayload,
36+
): Promise<AttributeDefinition> {
37+
const { data } = await client.put<AttributeDefinition>(`/attribute-definitions/${id}`, payload)
38+
return data
39+
}
40+
41+
export async function deleteAttributeDefinition(
42+
id: string,
43+
force = false,
44+
): Promise<void> {
45+
await client.delete(`/attribute-definitions/${id}`, { params: force ? { force: true } : {} })
46+
}

admin-ui/src/components/Layout.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export function Layout() {
5757
>
5858
Roles
5959
</NavLink>
60+
<NavLink
61+
to="/attributes"
62+
className={({ isActive }) =>
63+
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
64+
isActive
65+
? 'bg-blue-600 text-white'
66+
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
67+
}`
68+
}
69+
>
70+
Attributes
71+
</NavLink>
6072
<NavLink
6173
to="/policies"
6274
className={({ isActive }) =>
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useState, useEffect } from 'react'
2+
import { useQuery } from '@tanstack/react-query'
3+
import { listAttributeDefinitions } from '../api/attributeDefinitions'
4+
import type { AttributeDefinition } from '../types/attributeDefinition'
5+
6+
interface Props {
7+
/** Current attribute values from the user model. */
8+
attributes: Record<string, string>
9+
/** Called with the updated full attribute map when user edits. */
10+
onChange: (attributes: Record<string, string>) => void
11+
}
12+
13+
export function UserAttributeEditor({ attributes, onChange }: Props) {
14+
const { data: defsData, isLoading } = useQuery({
15+
queryKey: ['attribute-definitions', 'user'],
16+
queryFn: () => listAttributeDefinitions({ entity_type: 'user', page_size: 200 }),
17+
})
18+
19+
const definitions = defsData?.data ?? []
20+
21+
// Local state mirrors props; syncs upward via onChange
22+
const [values, setValues] = useState<Record<string, string>>(attributes)
23+
24+
useEffect(() => {
25+
setValues(attributes)
26+
}, [attributes])
27+
28+
function handleChange(key: string, value: string) {
29+
const next = { ...values }
30+
if (value === '' || value === undefined) {
31+
delete next[key]
32+
} else {
33+
next[key] = value
34+
}
35+
setValues(next)
36+
onChange(next)
37+
}
38+
39+
if (isLoading) {
40+
return <p className="text-sm text-gray-400">Loading attribute definitions...</p>
41+
}
42+
43+
if (definitions.length === 0) {
44+
return (
45+
<p className="text-sm text-gray-500">
46+
No attribute definitions yet.{' '}
47+
<a href="/attributes/create" className="text-blue-600 hover:underline">
48+
Create one
49+
</a>{' '}
50+
to start using user attributes.
51+
</p>
52+
)
53+
}
54+
55+
return (
56+
<div className="space-y-3 max-w-lg">
57+
{definitions.map((def) => (
58+
<AttributeField
59+
key={def.id}
60+
definition={def}
61+
value={values[def.key] ?? ''}
62+
onChange={(v) => handleChange(def.key, v)}
63+
/>
64+
))}
65+
{/* Show any orphaned attributes (set but no definition) */}
66+
{Object.keys(values)
67+
.filter((k) => !definitions.some((d) => d.key === k))
68+
.map((k) => (
69+
<div key={k} className="flex items-center gap-3">
70+
<label className="block text-sm font-medium text-gray-400 w-40 truncate">
71+
{k} <span className="text-xs">(no definition)</span>
72+
</label>
73+
<input
74+
type="text"
75+
value={values[k]}
76+
disabled
77+
className="flex-1 border border-gray-200 bg-gray-50 rounded-lg px-3 py-2 text-sm text-gray-400"
78+
/>
79+
<button
80+
type="button"
81+
onClick={() => handleChange(k, '')}
82+
className="text-xs text-red-500 hover:text-red-700"
83+
>
84+
Remove
85+
</button>
86+
</div>
87+
))}
88+
</div>
89+
)
90+
}
91+
92+
function AttributeField({
93+
definition,
94+
value,
95+
onChange,
96+
}: {
97+
definition: AttributeDefinition
98+
value: string
99+
onChange: (value: string) => void
100+
}) {
101+
const hasEnum = definition.allowed_values && definition.allowed_values.length > 0
102+
103+
return (
104+
<div className="flex items-center gap-3">
105+
<label className="block text-sm font-medium text-gray-700 w-40 truncate" title={definition.key}>
106+
{definition.display_name}
107+
{definition.description && (
108+
<span className="block text-xs font-normal text-gray-400 truncate" title={definition.description}>
109+
{definition.description}
110+
</span>
111+
)}
112+
</label>
113+
114+
{definition.value_type === 'boolean' ? (
115+
<select
116+
value={value}
117+
onChange={(e) => onChange(e.target.value)}
118+
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
119+
>
120+
<option value="">Not set</option>
121+
<option value="true">true</option>
122+
<option value="false">false</option>
123+
</select>
124+
) : hasEnum ? (
125+
<select
126+
value={value}
127+
onChange={(e) => onChange(e.target.value)}
128+
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
129+
>
130+
<option value="">Not set</option>
131+
{definition.allowed_values!.map((v) => (
132+
<option key={v} value={v}>
133+
{v}
134+
</option>
135+
))}
136+
</select>
137+
) : (
138+
<input
139+
type={definition.value_type === 'integer' ? 'number' : 'text'}
140+
value={value}
141+
onChange={(e) => onChange(e.target.value)}
142+
placeholder={definition.default_value ?? `Enter ${definition.value_type}`}
143+
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
144+
/>
145+
)}
146+
147+
<span className="text-xs text-gray-400 font-mono w-16 text-right">
148+
{'{'}user.{definition.key}{'}'}
149+
</span>
150+
</div>
151+
)
152+
}

0 commit comments

Comments
 (0)