You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3c1eb63
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,lint,plugin-security): a membership key may yield the caller's own roster rows (valueField: 'id') (#31174)
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiPx4rNcMCWwQpaDfQfZiVSp)_Fixes#31129
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #31176
## 维护者速读
- **改了什么:** 行级权限(RLS)的「成员键」现在可以写 `valueField: 'id'`:`current_user.KEY`
就是当前用户在名册对象上「自己那几行」的 id。员工表可以声明 `my_employee_ids`,请假单的规则写 `employee in
current_user.my_employee_ids`,员工就只看到自己的请假单。
- **为什么改:** 执行裁决 6708428544(A,「同意」)。HR、服务台、门户这类应用里「人就是一条记录」,以前只能给每行加
`owner_id` 或建关联表,发布时才发现此路不通。
- **风险与回滚:** 这个键只产出 `userField`
等于当前用户的那些行,本身不放任何数据;比较对象仍被严格检查(拿它去比别的对象的列照样拒绝)。上限
1,000、超限失效关闭不变。只放宽验收,无数据迁移;回滚即 revert。
- **先看:**
`packages/lint/src/validate-membership-key-declarations.ts`(`isRosterOwnId`:只认驱动提供的主键
`id`,作者自定义的同名字段照常判);`packages/lint/src/validate-rls-predicate-enforceability.ts`(`membershipKeyContextOf`:此键的目标对象即名册本身);`packages/plugins/plugin-security/src/object-membership-keys.ts`(读名册时只投影一次
`id`)。
- **两分钟验证:** `pnpm --filter @objectstack/plugin-security exec vitest run
src/object-membership-keys.test.ts -t 31129`,预期 4 个用例全绿:员工 u_x 只看到
LR-1、LR-4,u_y 只看到 LR-2,跨组织那行被墙挡掉。
- **需要你做的:** 无,席位落地。
## What changes
A declared membership key (`ObjectSchema.membershipKeys`) may now take
`valueField: 'id'`, the roster object's own primary key.
`current_user.KEY` is then the ids of the caller's own rows of the
roster, "the records I am" (an `employee`, `agent` or `customer`
roster).
The exclusion did not live in a Zod refine: `MembershipKeySchema` on
`main` has none for `id`. It lived in the lint judge
(`membership-key-value-field-invalid`, where `id` resolved to an untyped
empty slice and was refused as "not a reference") and in the contract
prose. This PR lifts it in both places:
- **spec** (`packages/spec/src/security/rls.zod.ts`): the membership-key
module docblock, the `valueField` JSDoc and `.describe()` now say F is a
reference column on X or X's own `id`, and that such a key holds ids of
X itself. Prose only; the U = F `superRefine` and the reserved-name
refusal are unchanged. References regenerated
(`content/docs/references/{data/object,security/rls}.mdx`).
- **lint, declaration judge**
(`validate-membership-key-declarations.ts`): new exported predicate
`isRosterOwnId(column, verdict)`: true only for the driver-provisioned
`id` (the object graph serves it as an injected column with an empty
slice). An author-declared field named `id` is judged by its own
definition, like any other column. `userField: 'id'` is still refused
(no user reference).
- **lint, policy-target judge**
(`validate-rls-predicate-enforceability.ts`, refusal 11):
`membershipKeyContextOf` uses the same predicate and sets such a key's
target to the declaring object. So `employee in
current_user.my_employee_ids` on a row whose `employee` looks up the
roster is accepted, as is `id in …` on the roster itself; a column
referencing another object, a non-reference column, or another object's
`id` is still refused.
- **lint, explanations** (`rule-explanations.ts`):
`membership-key-value-field-invalid` and
`rls-predicate-membership-key-target` explain the `id` case.
- **plugin-security, resolver** (`object-membership-keys.ts`): the
roster read projects `['id']` instead of `['id', 'id']` when F is `id`.
Value extraction, de-duplication, the `MEMBERSHIP_KEY_MAX_VALUES` bound,
the Layer 0 wall and fail-closed misses are the existing path,
unchanged.
- One changeset, `minor` on `@objectstack/spec`, `@objectstack/lint`,
`@objectstack/plugin-security`.
`id` is not treated as a reference column anywhere else: FLS masking
(`object-schema-fls-references.ts`, `membershipKeys:
recordOf(ruleEntry)`) only string-matches denied field names, and its
existing test already serves a key with `valueField: 'id'`;
`stack.zod.ts`, the registry's extension refusal, `metadata-protocol`
and `security-plugin.ts` read only key names, never `valueField`; no
dangling-reference audit or object-graph traversal reads it.
Earlier rulings in the touched files were kept intact (grep
`ruling|Ruling-ref|maintainer|⛔`): `rls.zod.ts` "Declarative membership
keys (ADR-0105 D11, the metadata twin; #25234 ruling A)", "Raised only
by a ruling" (the 1,000 bound), "#24164 ruling E′", and the maintainer's
mainstream-platform criterion at L648;
`validate-membership-key-declarations.ts` "[#25234 ruling A, refusals 3
to 6]"; `validate-rls-predicate-enforceability.ts` "#20158, #19995
ruling C", the two `⛔` notes (L531, L570) and the "refuse by ruling"
comparand note; `object-membership-keys.ts` "[#25234 ruling A]" and
"rider 1 of the ruling"; `rule-explanations.ts` "⛔ One copy". The only
ruled refusal that moved is the `id` half of refusal 6, lifted by ruling
6708428544.
## Tests
- `packages/spec/src/data/object-membership-keys.test.ts`: `valueField:
'id'` parses through `ObjectSchema` and the `object` metadata-type door.
- `packages/lint/src/validate-membership-key-declarations.test.ts`: `id`
accepted (with and without `filter`) and at the save door; an authored
text field named `id` still refused (rule + severity); `userField: 'id'`
still refused (rule + severity); the text-column refusal now also
asserts severity and the new hint.
-
`packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts`:
a new describe over an `hr_employee` roster with `my_employee_ids: {
userField: 'user', valueField: 'id' }`: accepted (`employee in …`,
negated, `id in …` on the roster); refused with
`rls-predicate-membership-key-target` + severity `error` (reference to
`hr_department`, user reference, text column, `id` of
`hr_leave_request`); CLI door and save door agree; the explanation
states the target.
- `packages/plugins/plugin-security/src/object-membership-keys.test.ts`
(shared harness, real `SecurityPlugin` + `ObjectQL` over SQLite): the
acceptance case from the order: u_x sees `lr_1`, `lr_4` only, u_y sees
`lr_2`, u_x's employee row in another organisation is dropped by the
wall; projection is `['id']`; a caller with no employee row and an
undeclared key see nothing; stub-host de-duplication, exactly-the-bound
resolves, over-the-bound stays unstaged; explain names the declaration
and links `lr_1` to row `e_05` via `membership:my_employee_ids`.
Ablations (each through `scripts/ablation-replace.mjs`, mutation proven
on disk, restored to the HEAD blob): disabling the `primaryKey` accept
in `valueFieldFault` reds the two accept tests; dropping
`targetOf.set(decl.key, decl.object)` reds the refusal cases (the key
falls to "not judged"); reverting the resolver's projection reds the
acceptance case.
## Acceptance notes
- The Fable full review (comment below) raised two should-fixes and four
nits; both should-fixes and two nits are fixed in this PR, and the delta
re-review passed. The two left: the "column not declared" hint and the U
= F schema message do not mention `id`; the first is unreachable for
`id` (always present), the second fires only where `id` is the wrong
answer.
- Skills and the RLS guide ("the person is a record" example) are the
`domain:skills` follow-up named in the ruling, not here.
## Verification
All readings at head `bad5140f` (branched from `origin/main` `9f61e06e`,
merged with `origin/main` `cbe20ab0` once before opening, regenerated
after).
<details><summary>Package tests and typecheck</summary>
- `pnpm --filter @objectstack/lint test` :: exit 0 — Test Files 197/197,
Tests 8340/8340
- `pnpm --filter @objectstack/plugin-security test` :: exit 0 — Test
Files 248/248, Tests 5799 passed, 45 skipped
- `pnpm --filter @objectstack/metadata-core test` :: exit 0 — Test Files
25/25, Tests 966/966
- `pnpm --filter @objectstack/spec exec vitest run
src/data/object-membership-keys.test.ts` :: exit 0 — 27/27
- `pnpm --filter @objectstack/lint typecheck`, `pnpm --filter
@objectstack/plugin-security typecheck`, `pnpm --filter
@objectstack/spec typecheck` :: exit 0
- `eslint --no-inline-config` over the touched `.ts` files :: exit 0
- After the review fixes: the two lint membership-key files,
`rule-id-barrel-exports` and `object-membership-keys.test.ts` re-run ::
exit 0 (56/56, 25/25)
</details>
<details><summary>Generated artifacts</summary>
- `pnpm --filter @objectstack/spec check:generated` :: exit 1 —
`check:docs` stale (the two reference pages), then `check:generated
--fix` :: exit 0, committed in `12458b7b`.
</details>
<details><summary>Derived gates (dispatch-gates --commands, 108
families)</summary>
- 104 run :: exit 0 on the first pass. Four answered exit 3
(prerequisite) and were rerun after building their prerequisite: `node
scripts/check-plugin-teardown-shape.mjs --self-test` (its pinned fixture
commit fetched) :: exit 0; `pnpm --filter @objectstack/spec run
check:skill-examples` (client-react built) :: exit 0; `pnpm check:i18n`
(its build closure) :: exit 0; `pnpm check:dual-build-cjs-loads` :: exit
0.
- `dispatch-gates --ran`: "108 derived famil(ies) accounted for — 108
run, 0 NOT-MEASURED".
- Full dogfood not run: the title has no `!` and `Clause-②` has the
`(widening)` arm.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_017ihL2bjT2KeFnLTtWu2yPW
<!-- signed: domain:spec#4 · marchtian ·
session_017ihL2eHDXrsKgGAUHq956U -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec,lint,plugin-security): a membership key may yield the caller's own roster rows (`valueField: 'id'`)
8
+
9
+
Clause-②: yes (widening)
10
+
Metadata-protocol: added
11
+
12
+
A declared membership key (`ObjectSchema.membershipKeys`) may now name `id`, the roster object's own primary key, as its `valueField`. `current_user.KEY` is then the ids of the signed-in user's own rows of that object, "the records I am". This covers the apps where the person is a record: an HR `employee`, a service-desk `agent`, a portal `customer`.
The key yields only rows whose `userField` is the caller, so it grants nothing by itself. What a policy grants with it is the author's choice, as for any other key.
26
+
27
+
-`os validate`, `os build`, `os lint` and the server's save gate no longer refuse `valueField: 'id'` with `membership-key-value-field-invalid`. They still refuse any other column that is not a `lookup`, `master_detail` or `user` reference. `id` is still no user reference as `userField`.
28
+
-`rls-predicate-membership-key-target` treats such a key as holding ids of the roster object itself. A policy may compare it with a column that references the roster, or with the roster's own `id`. A comparison with a column of another object, a non-reference column, or another object's `id` is refused as before.
29
+
- The security plugin's roster read projects `id` once for such a key. The set is de-duplicated, bounded by `MEMBERSHIP_KEY_MAX_VALUES` and fails closed over the bound, as before.
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -458,7 +458,7 @@ const result = ApiMethod.parse(data);
458
458
| Property | Type | Required | Description |
459
459
| :--- | :--- | :--- | :--- |
460
460
|**userField**|`string`| ✅ | The column of this object that holds the user a row belongs to: a single-valued user reference (`user`, or a `lookup` / `master_detail` to `sys_user`). A row counts for the signed-in user when this column holds their user id. |
461
-
|**valueField**|`string`| ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed. A policy compares the set with a column that references the same object, or with `id` on that object. Never the same column as `userField`. |
461
+
|**valueField**|`string`| ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed, or `id` for the ids of the signed-in user's own rows of this object (an employee roster's "the employee records I am"). A policy compares the set with a column that references the object the values belong to, or with `id` on that object. Never the same column as `userField`. |
462
462
|**filter**|`string`| optional | Optional condition a roster row must also meet, in canonical CEL over this object's own columns and within the row-level security subset a policy's `using` lowers (e.g. "active == true"). It may not read any `current_user.*` value. |
463
463
|**label**|`string`| optional | Human-readable name of the set |
464
464
|**description**|`string`| optional | What the set holds and who it is for |
Copy file name to clipboardExpand all lines: content/docs/references/security/rls.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,7 +171,7 @@ const result = DefaultRowScopeSchema.parse(data);
171
171
| Property | Type | Required | Description |
172
172
| :--- | :--- | :--- | :--- |
173
173
|**userField**|`string`| ✅ | The column of this object that holds the user a row belongs to: a single-valued user reference (`user`, or a `lookup` / `master_detail` to `sys_user`). A row counts for the signed-in user when this column holds their user id. |
174
-
|**valueField**|`string`| ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed. A policy compares the set with a column that references the same object, or with `id` on that object. Never the same column as `userField`. |
174
+
|**valueField**|`string`| ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed, or `id` for the ids of the signed-in user's own rows of this object (an employee roster's "the employee records I am"). A policy compares the set with a column that references the object the values belong to, or with `id` on that object. Never the same column as `userField`. |
175
175
|**filter**|`string`| optional | Optional condition a roster row must also meet, in canonical CEL over this object's own columns and within the row-level security subset a policy's `using` lowers (e.g. "active == true"). It may not read any `current_user.*` value. |
176
176
|**label**|`string`| optional | Human-readable name of the set |
177
177
|**description**|`string`| optional | What the set holds and who it is for |
0 commit comments