Skip to content

Commit 6a2b578

Browse files
docs: standardize NULL terminology across UI, docs, and code comments
Replace inconsistent phrases like "no default (null)", "no default", and "with no default" with explicit "NULL" or "default is NULL" throughout UI text, doc comments, inline comments, and documentation. Also improve default value UX in AttributeDefinitionForm (NULL badge, type-specific placeholders, icon clear button) and reorder search/filter controls on AttributeDefinitionsPage.
1 parent 7d2a7a3 commit 6a2b578

9 files changed

Lines changed: 47 additions & 38 deletions

File tree

admin-ui/src/components/AttributeDefinitionForm.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ interface Props {
2929
const VALUE_TYPES: ValueType[] = ['string', 'integer', 'boolean', 'list']
3030
const ENTITY_TYPES = SUPPORTED_ENTITY_TYPES
3131

32+
const DEFAULT_PLACEHOLDER: Record<ValueType, string> = {
33+
string: 'Type a value…',
34+
integer: 'Type an integer…',
35+
boolean: '', // boolean uses a select, not a text input
36+
list: 'JSON array, e.g., ["default"]',
37+
}
38+
3239
const inputCls =
3340
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
3441

@@ -186,39 +193,41 @@ export function AttributeDefinitionForm({
186193
onChange={(e) => setDefaultValue(e.target.value)}
187194
className={inputCls}
188195
>
189-
<option value="">No default (null)</option>
196+
<option value="">NULL</option>
190197
<option value="true">true</option>
191198
<option value="false">false</option>
192199
</select>
193200
) : (
194-
<div className="flex items-center gap-2">
201+
<div className="relative">
195202
<input
196203
type={valueType === 'integer' ? 'number' : 'text'}
197204
value={defaultValue}
198205
onChange={(e) => setDefaultValue(e.target.value)}
199-
placeholder={
200-
valueType === 'list'
201-
? 'No default (null) — or JSON array, e.g., ["default"]'
202-
: 'No default (null)'
203-
}
204-
className={`${inputCls} flex-1`}
206+
placeholder={DEFAULT_PLACEHOLDER[valueType]}
207+
className={`${inputCls} ${defaultValue ? 'pr-8' : 'pr-16'}`}
205208
/>
206-
{defaultValue && (
209+
{defaultValue ? (
207210
<button
208211
type="button"
209212
onClick={() => setDefaultValue('')}
210-
className="text-gray-400 hover:text-red-500 text-sm px-1"
213+
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-red-500 transition-colors"
211214
title="Clear to null"
212215
>
213-
&times;
216+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
217+
<path fillRule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clipRule="evenodd" />
218+
</svg>
214219
</button>
220+
) : (
221+
<span className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none font-mono text-xs bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">
222+
NULL
223+
</span>
215224
)}
216225
</div>
217226
)}
218227
<p className="text-xs text-gray-400 mt-1">
219228
{defaultValue
220229
? `Users without this attribute will be treated as having the value "${defaultValue}" when policies are evaluated. This value is applied by the proxy at query time — it is not stored on the user.`
221-
: 'When null: users without this attribute will have NULL substituted in policy expressions. In SQL, comparisons with NULL (e.g., tenant = NULL) evaluate to NULL, which is treated as false — so equality filters return zero rows. This is applied by the proxy at query time, not stored on the user.'}
230+
: 'The default is NULL. Users without this attribute will have NULL substituted in policy expressions. In SQL, comparisons with NULL (e.g., tenant = NULL) evaluate to NULL, which is treated as false — so equality filters return zero rows. This is applied by the proxy at query time, not stored on the user.'}
222231
</p>
223232
</div>
224233

admin-ui/src/components/DecisionFunctionModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ function buildCtxCompletions(evaluateContext: EvaluateContext, configStr: string
6161
for (const def of attrDefs) {
6262
const typeLabel = def.value_type === 'list' ? 'string[]' : def.value_type
6363
const defaultHint =
64-
def.default_value != null ? `default: ${def.default_value}` : 'default: null'
64+
def.default_value != null ? `default: ${def.default_value}` : 'default: NULL'
6565
items.push({
6666
label: `ctx.session.user.${def.key}`,
6767
type: 'variable' as const,
@@ -727,7 +727,7 @@ export function DecisionFunctionModal({
727727

728728
<p className="text-xs text-gray-400 -mt-3">
729729
Custom user attributes (e.g. <code className="bg-gray-100 px-1 rounded">ctx.session.user.clearance</code>) are <code className="bg-gray-100 px-1 rounded">null</code> when
730-
not set on a user or when their default is null. Use defensive checks
730+
not set on a user and the attribute&apos;s default is NULL. Use defensive checks
731731
like <code className="bg-gray-100 px-1 rounded">{'if (ctx.session.user.clearance == null)'}</code> before
732732
numeric comparisons, since <code className="bg-gray-100 px-1 rounded">null &gt;= 0</code> is <code className="bg-gray-100 px-1 rounded">true</code> in JavaScript.
733733
</p>

admin-ui/src/components/UserAttributeEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ function AddAttributeDropdown({
171171
{d.display_name} ({d.value_type})
172172
{d.default_value != null
173173
? ` — default: ${d.default_value}`
174-
: ' — no default (null)'}
174+
: ' — default: NULL'}
175175
</option>
176176
))}
177177
</select>

admin-ui/src/pages/AttributeDefinitionsPage.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,6 @@ export function AttributeDefinitionsPage() {
9999
</div>
100100

101101
<div className="flex items-center gap-3 mb-4">
102-
<input
103-
type="search"
104-
value={search}
105-
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
106-
placeholder="Search by key or display name…"
107-
className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
108-
/>
109102
<label className="text-sm text-gray-600 flex items-center gap-2">
110103
Entity type:
111104
<select
@@ -122,6 +115,13 @@ export function AttributeDefinitionsPage() {
122115
<option value="">All</option>
123116
</select>
124117
</label>
118+
<input
119+
type="search"
120+
value={search}
121+
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
122+
placeholder="Search by key or display name…"
123+
className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
124+
/>
125125
</div>
126126

127127
{deleteError && (

docs/permission-system.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,15 +320,15 @@ Reserved keys for `entity_type = "user"`: `username`, `id`, `user_id`, `roles`
320320

321321
When a user lacks an attribute that is referenced by a policy, the proxy resolves the value using the attribute definition's `default_value`:
322322

323-
| User has attribute? | `default_value` set? | Template variable result | Decision function context |
323+
| User has attribute? | `default_value` | Template variable result | Decision function context |
324324
|---|---|---|---|
325325
| Yes | (irrelevant) | User's actual value (typed literal) | User's actual value (typed JSON) |
326-
| No | Yes (`"acme"`) | `default_value` as typed literal | `default_value` as typed JSON |
327-
| No | No (null) | SQL `NULL` literal | JSON `null` |
326+
| No | Non-NULL (e.g. `"acme"`) | `default_value` as typed literal | `default_value` as typed JSON |
327+
| No | NULL (default) | SQL `NULL` literal | JSON `null` |
328328

329329
**SQL NULL semantics**: In SQL, comparisons with NULL (e.g., `column = NULL`) evaluate to NULL (three-valued logic), which is treated as false in WHERE clauses — so the user sees zero rows. This is standard SQL behavior consistent across DataFusion (which evaluates the filter in-process) and upstream databases like PostgreSQL (if the filter is pushed down). This applies to equality (`=`, `!=`), `IN`, and comparison operators (`>`, `<`). NULL is also handled naturally by `COALESCE` expressions. Note: `IS NULL` would match, so avoid writing filter expressions that use `IS NULL` with user attributes unless that behavior is intentional.
330330

331-
**Decision function null semantics**: Missing attributes with no default appear as `null` (not `undefined`). Equality checks work correctly (`null === "acme"``false`). However, numeric comparisons have a JS quirk: `null >= 0` is `true` because `null` coerces to `0`. Always guard numeric comparisons with a null check: `if (ctx.session.user.clearance == null) return { fire: true, reason: "missing clearance" }`.
331+
**Decision function null semantics**: Missing attributes whose default is NULL appear as `null` (not `undefined`). Equality checks work correctly (`null === "acme"``false`). However, numeric comparisons have a JS quirk: `null >= 0` is `true` because `null` coerces to `0`. Always guard numeric comparisons with a null check: `if (ctx.session.user.clearance == null) return { fire: true, reason: "missing clearance" }`.
332332

333333
**Undefined attributes**: If a policy references `{user.foo}` but no attribute definition named `foo` exists at all, the query fails with an error. This catches typos and stale policies referencing deleted attributes.
334334

@@ -400,7 +400,7 @@ curl -X POST ... \
400400

401401
See [Template variables](#template-variables) above. `{user.KEY}` references produce typed literals based on the attribute definition's `value_type`.
402402

403-
**Missing attributes**: if a user does not have an attribute set, the proxy resolves the value from the attribute definition's `default_value`. If a default is set, it is substituted as a typed literal. If no default is set (null), SQL `NULL` is substituted — comparisons with `NULL` evaluate to `NULL` (three-valued logic), which is treated as false in WHERE clauses, so the user sees zero rows. If the attribute has no definition at all, the query fails with an error. See [Missing attribute behavior](#missing-attribute-behavior) for the full resolution table.
403+
**Missing attributes**: if a user does not have an attribute set, the proxy resolves the value from the attribute definition's `default_value`. If a default is set, it is substituted as a typed literal. If the default is NULL, SQL `NULL` is substituted — comparisons with `NULL` evaluate to `NULL` (three-valued logic), which is treated as false in WHERE clauses, so the user sees zero rows. If the attribute has no definition at all, the query fails with an error. See [Missing attribute behavior](#missing-attribute-behavior) for the full resolution table.
404404

405405
### Using attributes in decision functions
406406

@@ -434,7 +434,7 @@ User attributes are available as first-class fields on `ctx.session.user` with c
434434

435435
Note: integer and boolean attributes appear as native JSON types in the decision function context (not strings). List attributes appear as JSON arrays of strings.
436436

437-
**Missing attributes in decision context**: attributes the user lacks are resolved via `default_value` from the attribute definition. If a default is set, the typed value appears on `ctx.session.user` as if the user had that attribute. If no default is set, the field is `null` (not `undefined`). Use `== null` checks before numeric comparisons, since `null >= 0` is `true` in JavaScript. See [Missing attribute behavior](#missing-attribute-behavior) for the full resolution table.
437+
**Missing attributes in decision context**: attributes the user lacks are resolved via `default_value` from the attribute definition. If a default is set, the typed value appears on `ctx.session.user` as if the user had that attribute. If the default is NULL, the field is `null` (not `undefined`). Use `== null` checks before numeric comparisons, since `null >= 0` is `true` in JavaScript. See [Missing attribute behavior](#missing-attribute-behavior) for the full resolution table.
438438

439439
### Cache behavior
440440

docs/security-vectors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,7 @@ Note: `tenant` is no longer a reserved key — it is a regular custom attribute.
864864

865865
**Bug**: `mangle_vars()` used `unwrap_or("")` for missing attributes with no error or warning. Decision function context omitted missing attributes entirely (`undefined` in JS), causing `undefined >= 0` to silently evaluate to `false` in numeric comparisons.
866866

867-
**Defense**: `resolve_user_attribute_defaults()` merges user attributes with definition defaults. Missing attributes with a `default_value` get that value substituted (typed literal in SQL, typed JSON in decision context). Missing attributes with no default get SQL `NULL` (zero rows for equality) and JSON `null` (distinguishable from `undefined`). References to completely undefined attributes (no definition) return an error. The centralized helper is used in all three paths: `mangle_vars` (row filters + masks), query-level decision context (`handle_query`), and visibility-level decision context (`build_typed_json_attributes`).
867+
**Defense**: `resolve_user_attribute_defaults()` merges user attributes with definition defaults. Missing attributes with a `default_value` get that value substituted (typed literal in SQL, typed JSON in decision context). Missing attributes whose default is NULL get SQL `NULL` (zero rows for equality) and JSON `null` (distinguishable from `undefined`). References to completely undefined attributes (no definition) return an error. The centralized helper is used in all three paths: `mangle_vars` (row filters + masks), query-level decision context (`handle_query`), and visibility-level decision context (`build_typed_json_attributes`).
868868

869869
**Test**: Unit: `test_mangle_vars_missing_attr_with_default`, `test_mangle_vars_missing_attr_null_default`, `test_mangle_vars_missing_attr_no_definition`, `test_mangle_vars_missing_attr_default_integer`, `test_mangle_vars_missing_attr_default_list`, `test_mangle_vars_present_attr_ignores_default`, `test_resolve_user_attribute_defaults`. Integration: `row_filter_missing_attr_uses_default`, `row_filter_missing_attr_null_default`, `row_filter_attr_present_ignores_default`, `column_mask_missing_attr_uses_default`, `column_mask_missing_attr_null_default`, `decision_fn_missing_attr_uses_default`.
870870

proxy/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ Custom key-value attributes on users, governed by a schema-first attribute defin
138138

139139
**Template variables**: `{user.KEY}` in filter/mask expressions. Built-in fields (`{user.username}`, `{user.id}`) take priority via `match` arms in `UserVars::get()`, preventing attribute override attacks. Custom attributes (including `tenant`) fall through to the resolved attribute map (user values + definition defaults via `resolve_user_attribute_defaults()`).
140140

141-
**Missing attribute resolution**: When a user lacks an attribute referenced by a policy, the proxy resolves it from the attribute definition's `default_value`. If set, the default is used as a typed literal. If null, SQL `NULL` is substituted (comparisons with NULL evaluate to NULL → treated as false in WHERE → zero rows). If no definition exists, the query errors. This is handled centrally by `resolve_user_attribute_defaults()` in `hooks/policy.rs`, used in all three paths: template variables, query-level decision context, and visibility-level decision context (`build_typed_json_attributes` in `engine/mod.rs`).
141+
**Missing attribute resolution**: When a user lacks an attribute referenced by a policy, the proxy resolves it from the attribute definition's `default_value`. If a non-NULL default is set, it is used as a typed literal. If the default is NULL (the default), SQL `NULL` is substituted (comparisons with NULL evaluate to NULL → treated as false in WHERE → zero rows). If no definition exists, the query errors. This is handled centrally by `resolve_user_attribute_defaults()` in `hooks/policy.rs`, used in all three paths: template variables, query-level decision context, and visibility-level decision context (`build_typed_json_attributes` in `engine/mod.rs`).
142142

143-
**Decision function context**: Custom attributes are flattened as first-class fields on `ctx.session.user` (e.g., `ctx.session.user.region`, `ctx.session.user.tenant`) with typed JSON values. Missing attributes with a `default_value` appear as their typed default; missing with no default appear as `null` (not `undefined`). Built-in fields (`id`, `username`, `roles`) always take priority. `ctx.session.time.now` is an ISO 8601 / RFC 3339 timestamp of the evaluation time (not session start).
143+
**Decision function context**: Custom attributes are flattened as first-class fields on `ctx.session.user` (e.g., `ctx.session.user.region`, `ctx.session.user.tenant`) with typed JSON values. Missing attributes with a `default_value` appear as their typed default; missing attributes whose default is NULL appear as `null` (not `undefined`). Built-in fields (`id`, `username`, `roles`) always take priority. `ctx.session.time.now` is an ISO 8601 / RFC 3339 timestamp of the evaluation time (not session start).
144144

145145
**Save-time expression validation**: `validate_expression()` in `hooks/policy.rs` dry-run parses filter/mask expressions at policy create/update time and returns 422 if the syntax is unsupported. Called from `validate_definition()` in `dto.rs`.
146146

proxy/src/hooks/policy.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub struct AttrDefInfo {
8787

8888
/// Merge user's actual attributes with defaults from attribute definitions.
8989
/// Missing attributes with a default_value get that value inserted.
90-
/// Missing attributes with no default get a null sentinel (value_type="null").
90+
/// Missing attributes whose default is NULL get a null sentinel (value_type="null").
9191
pub fn resolve_user_attribute_defaults(
9292
user_attrs: &HashMap<String, TypedAttribute>,
9393
attr_defs: &HashMap<String, AttrDefInfo>,
@@ -180,7 +180,7 @@ struct VarMapping {
180180
///
181181
/// Uses `resolve_user_attribute_defaults` to merge user attributes with definition defaults
182182
/// before substitution. Missing attributes with a default produce typed literals; missing
183-
/// attributes with no default produce SQL NULL; references to undefined attributes error.
183+
/// attributes with a NULL default produce SQL NULL; references to undefined attributes error.
184184
fn mangle_vars(template: &str, vars: &UserVars) -> Result<(String, Vec<VarMapping>), String> {
185185
let mut result = template.to_string();
186186
let mut mappings = Vec::new();
@@ -4850,7 +4850,7 @@ Javy.IO.writeSync(1, new TextEncoder().encode(JSON.stringify(result)));
48504850
// Missing + default → uses default
48514851
assert_eq!(resolved["clearance"].value, "0");
48524852
assert_eq!(resolved["clearance"].value_type, "integer");
4853-
// Missing + no default → null sentinel
4853+
// Missing + NULL default → null sentinel
48544854
assert_eq!(resolved["region"].value_type, "null");
48554855
}
48564856

proxy/tests/policy_enforcement.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7821,7 +7821,7 @@ async fn row_filter_missing_attr_uses_default() {
78217821
assert_eq!(rows[1][1], "acme");
78227822
}
78237823

7824-
/// Row filter: user lacks attribute, no default_value → SQL NULL → zero rows.
7824+
/// Row filter: user lacks attribute, default is NULL → SQL NULL → zero rows.
78257825
#[tokio::test]
78267826
async fn row_filter_missing_attr_null_default() {
78277827
let _pg = require_postgres!();
@@ -7992,7 +7992,7 @@ async fn column_mask_missing_attr_uses_default() {
79927992
assert_eq!(rows[1][1], "B***_guest");
79937993
}
79947994

7995-
/// Column mask: user lacks attribute, no default → mask uses NULL.
7995+
/// Column mask: user lacks attribute, default is NULL → mask uses NULL.
79967996
#[tokio::test]
79977997
async fn column_mask_missing_attr_null_default() {
79987998
let _pg = require_postgres!();
@@ -8011,7 +8011,7 @@ async fn column_mask_missing_attr_null_default() {
80118011
let ds_id = server.create_datasource("ds_cmdef02", "open").await;
80128012
server.discover(ds_id, &[schema]).await;
80138013

8014-
// Attribute definition with no default → NULL
8014+
// Attribute definition with NULL default
80158015
server
80168016
.create_attribute_definition("label", "user", "string", None)
80178017
.await;
@@ -8081,7 +8081,7 @@ async fn decision_fn_missing_attr_uses_default() {
80818081
let _user_id = server.create_user("gina", "GinaPass12!", ds_id).await;
80828082

80838083
// Decision function: fire only if clearance === 0 (the default)
8084-
// If the user had no attribute and no default injection, ctx.session.user.clearance
8084+
// If the user had no attribute and the default were not injected, ctx.session.user.clearance
80858085
// would be undefined → undefined === 0 is false → filter would NOT fire → all rows visible.
80868086
let decision_fn_id = create_decision_fn(
80878087
&server,

0 commit comments

Comments
 (0)