Skip to content

Commit e524137

Browse files
feat: drop built-in tenant column, make tenant a custom user attribute
Remove the `tenant` column from `proxy_user` so that tenant is managed entirely through the ABAC attribute definition system. This eliminates special-casing for tenant across the proxy, admin API, and UI. - Add migration 055 to drop the tenant column - Remove tenant from entity model, CLI args, DTOs, and auth flow - Update policy hook to resolve tenant from user attributes - Remove hardcoded tenant dummy from validate_expression - Update integration tests to create tenant as a custom attribute - Update admin-ui types, forms, list pages, and test factories - Update docs and security vectors to reflect tenant as custom attribute
1 parent 595d4ce commit e524137

42 files changed

Lines changed: 556 additions & 471 deletions

Some content is hidden

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

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ The shared pool is safe for all authorized users of a datasource: Pool = "how to
126126

127127
`PolicyHook` injects row filters and column transforms at the DataFusion logical plan level via `transform_up`. The filter is applied below the `TableScan` node — it cannot be bypassed by table aliases, CTEs, or subqueries, since DataFusion inlines those into the plan before transformation.
128128

129-
Template variable substitution (`{user.tenant}`, etc.) uses parse-then-substitute: the filter expression is parsed into a `DataFusion Expr` tree first, then placeholder identifiers are replaced with typed `Expr::Literal` values. The user's tenant/username never passes through the SQL parser, preventing injection even if the value contains SQL syntax.
129+
Template variable substitution (`{user.username}`, `{user.id}`, custom attributes like `{user.tenant}`, etc.) uses parse-then-substitute: the filter expression is parsed into a `DataFusion Expr` tree first, then placeholder identifiers are replaced with typed `Expr::Literal` values. The user's values never pass through the SQL parser, preventing injection even if the value contains SQL syntax.
130130

131131
### Permissions Model
132132

@@ -136,7 +136,7 @@ BetweenRows enforces a two-layer access control model:
136136

137137
**Data plane** — controlled by two independent mechanisms:
138138
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.
139-
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.
139+
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.username}`, `{user.id}`) and custom user attributes (`{user.KEY}`, e.g., `{user.tenant}`) for attribute-based access control (ABAC). Optional decision functions (JavaScript/WASM) provide programmable policy gates.
140140

141141
See `docs/permission-system.md` for the full policy system user guide.
142142

@@ -189,7 +189,7 @@ There is currently no automated performance regression suite. Meaningful regress
189189
All primary keys are UUIDs. The admin store uses SQLite by default (configurable via `DATABASE_URL`).
190190

191191
```
192-
proxy_user (id UUID, username, password_hash, tenant, is_admin, is_active, …)
192+
proxy_user (id UUID, username, password_hash, is_admin, is_active, attributes JSON, …)
193193
data_source (id UUID, name, ds_type, config JSON, secure_config encrypted,
194194
is_active, access_mode, last_sync_at, last_sync_result, …)
195195
data_source_access (id UUID, user_id?, role_id?, data_source_id, assignment_scope, …)

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ BetweenRows sits between your users and your data sources, applying security pol
99
## Why
1010

1111
- **No application changes** — policies are enforced at the proxy layer, not in your app code
12-
- **Row-level filtering** — automatically filter rows based on user identity (tenant, role, department)
12+
- **Row-level filtering** — automatically filter rows based on user identity (role, department, tenant)
1313
- **Column masking** — mask sensitive columns (SSN, email, salary) with expressions, not views
1414
- **Column & table deny** — hide columns or entire tables from specific users or roles
1515
- **Full audit trail** — every query is logged with the original SQL, rewritten SQL, and policies applied
@@ -71,9 +71,9 @@ Open **http://localhost:5435** and log in with your admin credentials.
7171

7272
1. **Add a data source** — Go to Data Sources → Create. Enter your data source connection details and test the connection.
7373
2. **Discover the schema** — Click "Discover Catalog" on your new data source. Select which schemas, tables, and columns to expose through the proxy.
74-
3. **Create a user** — Go to Users → Create. Set a username, password, and tenant.
74+
3. **Create a user** — Go to Users → Create. Set a username and password.
7575
4. **Grant access** — On the data source page, assign the user (or a role) access to the data source.
76-
5. **Create a policy** — Go to Policies → Create. For example, a `row_filter` policy with expression `tenant = {user.tenant}` to isolate rows by tenant.
76+
5. **Create a policy** — Go to Policies → Create. For example, a `row_filter` policy with expression `tenant = {user.tenant}` to isolate rows by tenant. (The `tenant` attribute must be defined as a custom attribute definition first.)
7777
6. **Assign the policy** — On the data source page, assign the policy to a user, role, or all users.
7878
7. **Connect through the proxy** — The user can now query through BetweenRows:
7979
```bash
@@ -127,7 +127,7 @@ Key concepts:
127127
- **ABAC** — define custom user attributes (e.g., department, region, clearance level) and use them in policy expressions via `{user.<key>}` template variables
128128
- **Decision functions** — optional JavaScript functions compiled to WASM that gate policy evaluation based on arbitrary logic (time windows, multi-attribute conditions, external state)
129129
- **Assignment scopes** — policies can be assigned to individual users, roles, or all users on a data source
130-
- **Template variables**`{user.tenant}`, `{user.username}`, `{user.id}`, and custom attributes are substituted at query time, making policies dynamic per user
130+
- **Template variables**`{user.username}`, `{user.id}` (built-in), and custom attributes like `{user.tenant}`, `{user.region}` are substituted at query time, making policies dynamic per user
131131
- **Access modes** — data sources can be set to `open` (all tables accessible by default) or `policy_required` (tables are hidden unless a `column_allow` policy grants access)
132132
- **Deny wins** — deny policies are evaluated before permit policies and cannot be overridden
133133
- **Visibility follows access** — denied columns and tables are removed from the user's schema at connection time, so they don't appear in client tools like TablePlus or DBeaver
@@ -157,11 +157,11 @@ Create users without the UI — useful for scripting and automation. If you're l
157157

158158
```bash
159159
# Docker
160-
docker exec -it <container> proxy user create --username alice --password secret --tenant acme
161-
docker exec -it <container> proxy user create --username rescue --password secret --tenant default --admin
160+
docker exec -it <container> proxy user create --username alice --password secret
161+
docker exec -it <container> proxy user create --username rescue --password secret --admin
162162

163163
# From source
164-
cargo run -p proxy -- user create --username alice --password secret --tenant acme
164+
cargo run -p proxy -- user create --username alice --password secret
165165
```
166166

167167
## Roadmap

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe('createUser', () => {
7878
it('POSTs payload to /users', async () => {
7979
const user = makeUser()
8080
mockClient.post.mockResolvedValue({ data: user })
81-
const payload = { username: 'newuser', password: 'pw', tenant: 'acme', is_admin: false }
81+
const payload = { username: 'newuser', password: 'pw', is_admin: false }
8282
const result = await createUser(payload)
8383
expect(mockClient.post).toHaveBeenCalledWith('/users', payload)
8484
expect(result).toEqual(user)
@@ -89,7 +89,7 @@ describe('updateUser', () => {
8989
it('PUTs payload to /users/:id', async () => {
9090
const user = makeUser()
9191
mockClient.put.mockResolvedValue({ data: user })
92-
const payload = { tenant: 'new-tenant', is_admin: true }
92+
const payload = { is_admin: true }
9393
const result = await updateUser('u-1', payload)
9494
expect(mockClient.put).toHaveBeenCalledWith('/users/u-1', payload)
9595
expect(result).toEqual(user)

admin-ui/src/components/DecisionFunctionModal.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ function buildCtxCompletions(evaluateContext: EvaluateContext, configStr: string
4949
const items = [
5050
{ label: 'ctx.session.user.id', type: 'variable' as const, detail: 'UUID' },
5151
{ label: 'ctx.session.user.username', type: 'variable' as const },
52-
{ label: 'ctx.session.user.tenant', type: 'variable' as const },
5352
{ label: 'ctx.session.user.roles', type: 'variable' as const, detail: 'string[]' },
5453
{ label: 'ctx.session.time.now', type: 'variable' as const, detail: 'ISO 8601 timestamp' },
5554
{ label: 'ctx.session.time.hour', type: 'variable' as const, detail: '0-23' },
@@ -359,7 +358,6 @@ export function DecisionFunctionModal({
359358
user: {
360359
id: currentUser?.id ?? '00000000-0000-0000-0000-000000000000',
361360
username: currentUser?.username ?? 'testuser',
362-
tenant: currentUser?.tenant ?? 'default',
363361
roles: ['analyst'],
364362
...testAttrs, // flattened — matches build_user_object() in context.rs
365363
},

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,22 @@ describe('ExpressionEditor', () => {
2929
<ExpressionEditor
3030
value=""
3131
onChange={() => {}}
32-
placeholder="organization_id = {user.tenant}"
32+
placeholder="organization_id = {user.username}"
3333
templateItems={[]}
3434
/>,
3535
)
36-
expect(screen.getByPlaceholderText('organization_id = {user.tenant}')).toBeTruthy()
36+
expect(screen.getByPlaceholderText('organization_id = {user.username}')).toBeTruthy()
3737
})
3838

3939
it('renders with initial value', () => {
4040
renderWithProviders(
4141
<ExpressionEditor
42-
value="tenant = {user.tenant}"
42+
value="owner = {user.username}"
4343
onChange={() => {}}
4444
templateItems={[]}
4545
/>,
4646
)
47-
expect(screen.getByDisplayValue('tenant = {user.tenant}')).toBeTruthy()
47+
expect(screen.getByDisplayValue('owner = {user.username}')).toBeTruthy()
4848
})
4949

5050
it('calls onChange when value changes', () => {

admin-ui/src/components/ExpressionEditor.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ interface ExpressionEditorProps {
1717
}
1818

1919
const BUILTIN_VARS = [
20-
{ label: '{user.tenant}', detail: 'string', apply: '{user.tenant}' },
2120
{ label: '{user.username}', detail: 'string', apply: '{user.username}' },
2221
{ label: '{user.id}', detail: 'string', apply: '{user.id}' },
2322
]

admin-ui/src/components/PolicyForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ export function PolicyForm({ initial, onSubmit, submitLabel, isSubmitting, error
612612
<ExpressionEditor
613613
value={filterExpression}
614614
onChange={setFilterExpression}
615-
placeholder="organization_id = {user.tenant}"
615+
placeholder="department = {user.department}"
616616
templateItems={templateItems}
617617
onValidate={(expr) => validateExpression(expr, false)}
618618
/>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ const mockListUsers = listUsers as ReturnType<typeof vi.fn>
2424
beforeEach(() => vi.clearAllMocks())
2525

2626
describe('UserAssignmentPanel', () => {
27-
const alice = makeUser({ id: 'u-alice', username: 'alice', tenant: 'acme' })
28-
const bob = makeUser({ id: 'u-bob', username: 'bob', tenant: 'acme', is_admin: true })
27+
const alice = makeUser({ id: 'u-alice', username: 'alice' })
28+
const bob = makeUser({ id: 'u-bob', username: 'bob', is_admin: true })
2929

3030
it('renders loading state initially', () => {
3131
mockListUsers.mockReturnValue(new Promise(() => {}))

admin-ui/src/components/UserAssignmentPanel.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ export function UserAssignmentPanel({ datasourceId }: UserAssignmentPanelProps)
9494
/>
9595
<div className="flex-1 min-w-0">
9696
<span className="text-sm font-medium text-gray-900">{user.username}</span>
97-
<span className="text-xs text-gray-500 ml-2">{user.tenant}</span>
9897
</div>
9998
{user.is_admin && (
10099
<span className="text-xs bg-purple-100 text-purple-700 rounded-full px-2 py-0.5 font-medium">

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ describe('UserForm – create mode', () => {
1515
const { container } = renderWithProviders(
1616
<UserForm mode="create" onSubmit={vi.fn()} onCancel={vi.fn()} />,
1717
)
18-
// create mode: textboxes are [username, tenant, email, display_name]
18+
// create mode: textboxes are [username, email, display_name]
1919
const textboxes = screen.getAllByRole('textbox')
20-
expect(textboxes.length).toBeGreaterThanOrEqual(2) // at least username + tenant
20+
expect(textboxes.length).toBeGreaterThanOrEqual(1) // at least username
2121
expect(getPasswordInput(container)).toBeTruthy()
2222
expect(screen.getByRole('button', { name: /create user/i })).toBeInTheDocument()
2323
})
@@ -38,11 +38,10 @@ describe('UserForm – create mode', () => {
3838
<UserForm mode="create" onSubmit={onSubmit} onCancel={vi.fn()} />,
3939
)
4040

41-
// create mode textboxes: [0]=username, [1]=tenant, [2]=email, [3]=displayName
41+
// create mode textboxes: [0]=username, [1]=email, [2]=displayName
4242
const textboxes = screen.getAllByRole('textbox')
4343
await user.type(textboxes[0], 'alice') // username
4444
await user.type(getPasswordInput(container), 'Test@123!') // password
45-
await user.type(textboxes[1], 'acme') // tenant
4645

4746
// Submit via button
4847
await user.click(screen.getByRole('button', { name: /create user/i }))
@@ -51,7 +50,6 @@ describe('UserForm – create mode', () => {
5150
expect.objectContaining({
5251
username: 'alice',
5352
password: 'Test@123!',
54-
tenant: 'acme',
5553
is_admin: false,
5654
}),
5755
)
@@ -73,14 +71,14 @@ describe('UserForm – edit mode', () => {
7371
const { container } = renderWithProviders(
7472
<UserForm
7573
mode="edit"
76-
initialValues={{ username: 'bob', tenant: 'acme', is_admin: false, is_active: true }}
74+
initialValues={{ username: 'bob', is_admin: false, is_active: true }}
7775
onSubmit={vi.fn()}
7876
onCancel={vi.fn()}
7977
/>,
8078
)
8179
// In edit mode, no password field
8280
expect(getPasswordInput(container)).toBeNull()
83-
// In edit mode, the username textbox is NOT rendered; textboxes are [tenant, email, displayName]
81+
// In edit mode, the username textbox is NOT rendered; textboxes are [email, displayName]
8482
const textboxes = screen.getAllByRole('textbox')
8583
// No input should have the username value in edit mode (username is not shown)
8684
expect(textboxes.some((t) => (t as HTMLInputElement).value === 'bob')).toBe(false)
@@ -90,7 +88,7 @@ describe('UserForm – edit mode', () => {
9088
renderWithProviders(
9189
<UserForm
9290
mode="edit"
93-
initialValues={{ tenant: 'acme', is_active: true }}
91+
initialValues={{ is_active: true }}
9492
onSubmit={vi.fn()}
9593
onCancel={vi.fn()}
9694
/>,
@@ -106,7 +104,7 @@ describe('UserForm – edit mode', () => {
106104
const { container } = renderWithProviders(
107105
<UserForm
108106
mode="edit"
109-
initialValues={{ tenant: 'acme', is_admin: false, is_active: true }}
107+
initialValues={{ is_admin: false, is_active: true }}
110108
onSubmit={onSubmit}
111109
onCancel={vi.fn()}
112110
/>,

0 commit comments

Comments
 (0)