Skip to content

Commit 2972097

Browse files
docs(security): state the RLS write check as the gate enforces it in ADR-0066 and the data skill (#20298)
Fixes #20275 Clause-②: no Two governed texts said the RLS write check wrong. ADR-0066's combination rule (item 3) called row policies "OR-combined (any matching policy admits the row)" for every operation; the published data skill's RLS section called `using` the read filter and `check` the write filter, with no stand-in rule, no per-row statement and no refusal rule. Both now say what `main` enforces, in the wording the schema already carries. #20268 landed the non-governed half (the `rls.zod.ts` overview and describe texts, the docs pages); this PR is the governed half and waits on the maintainer's hand (Tier H: `docs/adr/**` and `skills/**`). No code change. Provenance of the three facts the skill now states: every-row judging of inserts and updates landed in #19988 and #20012; the refusal of a non-blank `check` on a `select` / `delete` policy in #20167; the per-operation default and the default-posture wording in #20268. ## What changed ### `docs/adr/0066-unified-authorization-model.md` — item 3 of "Precedence / combination semantics" One sentence. It now reads: on a read, the applicable policies' `using` predicates OR-combine (any matching policy admits the row); on an insert or update, the check is chosen once per operation across the applicable policies — the declared `check` predicates when any declares one, else each applicable policy's `using` standing in — and the chosen predicates OR-combine over every row written. The tenant-isolation clause and the superuser-bypass sentence are unchanged; Status, headings and the other items are untouched. The enforcing code is cited as symbol anchors, so `check:adr-symbol-anchors` holds them: - `packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler.compileFilter` — read side: OR-combines the applicable policies' `using` (its docblock: "Multiple policies for the same object/operation are OR-combined"). - `packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies` — write side: takes the applicable policies that declare `check`; when none does, the ones that declare `using` (the platform ownership floor kept exactly when the pre-image gate kept it, `keepOwnershipFloor`). `computeWriteCheckFilter` then hands that set to `compileFilter` with the `check` clause, which OR-combines. ### `skills/objectstack-data/rules/security.md` — the "Row-Level Security (RLS)" paragraph and its example comments The paragraph now states, in this order: 1. `using` admits rows — what a `select` policy lets the caller read, and the existing rows an `update`/`delete` policy lets it change or remove; on a read the applicable `using` OR-combine, then AND into the query. 2. `check` is judged on every row an `insert`/`update` writes (array inserts and `multi: true` included; one failing row refuses the write), chosen per operation: when any applicable policy declares `check`, only those decide (OR-combined); else each applicable `using` stands in. 3. A non-blank `check` on a `select`/`delete` policy is refused. 4. The default posture, in the schema overview's words ("Default deny, among the policies that apply … when none applies the policies restrict nothing (the tenant wall still applies)"), plus the one clause the review of the non-governed half named: an `update`/`delete` target with no write-class `using` is bounded by the caller's `select` policies (the by-id pre-image gate derives its scope from the caller's SELECT narrowing when no write-class `using` applies; not for `insert`, not under the read-side superuser bypass). The example's two comments (`// read scope` / `// write scope`) now read `// rows readable / targetable` and `// every row written`. Wording follows the `RowLevelSecurityPolicySchema.using` / `.check` describe texts and the `rls.zod.ts` overview as landed in `3f86dc52`; no second phrasing of the same fact was introduced. No issue or PR number appears in the skill text (`check:doc-authoring` refuses one under `skills/`). ### Paying the token ceiling `check:skills-token-ratchet` holds `security.md` at 2543 tokens with headroom 0. The RLS paragraph grew by 687 bytes and the two comments by 22; the difference is paid in the same file by removing sentences the file already states elsewhere — nothing moved to another file, the ceiling is untouched: - the `permissions`-vs-`permissionSets` bullet no longer repeats the code comment two lines above it (the refusal text, the `ObjectStackDefinitionSchema` source and "never a silent drop" stay); - the `permission_set_id` warning no longer says "record id" twice; - the RLS source line cites `rls.zod.ts` once (policy shape, grammar, `check` composition) instead of `permission.zod.ts` again (already cited under RBAC); - the owner-scoping bullet, the `requiredPermissions` paragraph (its enforcer was already named under `maskingRule`), the platform-global paragraph and its blockquote lose filler words, no facts. ## Readings Line/token budget (tokens = `ceil(utf8 bytes / 4)`, the ratchet's own unit; `3f86dc52` → `4b330f38`): | surface | lines before → after | tokens before → after | |---|---|---| | `skills/objectstack-data/rules/security.md` | 214 → 216 | 2543 → 2541 (ceiling 2543, headroom 2) | | `skills/objectstack-data/**` (17 files) | 3807 → 3809 | 40934 → 40932 | | `skills/**/SKILL.md` (10 files) | 4402 → 4402 | 51903 → 51903 | | whole `skills/` tree (65 files) | 13427 → 13429 | 155990 → 155988 | | ratchet "bundle total (whole shipped tree)" | — | 153970 → 153968 | | `docs/adr/0066-unified-authorization-model.md` | 114 → 114 | 5096 → 5224 (no token ratchet on `docs/adr/**`) | The +2 lines in the skill file are the natural wrapping of a longer paragraph; the gate that prices `skills/**` is the token ratchet, and it went down. Gates — derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` off the merge base at `4b330f38`: 29 commands; every exit code captured before any pipe; `--ran` reconciliation: "29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN … all 29 recorded an exit code and none of them is 3". - `node scripts/check-skills-token-ratchet.mjs` → 0 — "`skills/objectstack-data/rules/security.md` is 2541 tokens (ceiling 2543; headroom 2)"; `--self-test` → 0 (65 cases) - `node scripts/check-adr-symbol-anchors.mjs` → 0 — "2125 anchors across 140 records resolve"; `--self-test` → 0 - `node scripts/check-adr-links.mjs` → 0; `--self-test` → 0 - `node scripts/check-ci-filter-parity.mjs` → 0 - `node scripts/check-closing-keyword-parity.mjs` → 0; `--self-test` → 0 - `node scripts/check-comment-mask-corpus.mjs` → 0 - `node scripts/check-doc-route-spelling.mjs --advisory` → 0; `--self-test` → 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` → first run exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` not built — a refusal, not a measurement); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under `os-verify-lock.sh` (VERDICT command-exit 0, held 240s) → 0 - `pnpm check:adr-anchors` → 0 · `check:agent-test-spelling` → 0 · `check:corpus-claim-drift` → 0 · `check:cross-package-test-inputs` → 0 · `check:doc-authoring` → 0 · `check:driver-memory-census` → 0 · `check:gitlink-declared` → 0 · `check:nul-bytes` → 0 · `check:pm-governed-merges` → 0 · `check:pm-prior-rulings` → 0 · `check:refd-timer-probe` → 0 · `check:role-word` → 0 · `check:skill-compatibility` → 0 · `check:skill-frame-sync` → 0 · `check:skill-identifier-liveness` → 0 · `check:watch-hint-literal` → 0 Reverse verification of the ADR anchors (the gate lists only findings, so resolution was proven by failure): with `writeCheckPolicies` mutated to `writeCheckPoliciesNOPE` in the committed ADR, `check-adr-symbol-anchors` exits 1 with `[unresolved-symbol] docs/adr/0066-unified-authorization-model.md:93`; restored with `git checkout HEAD -- PATH`, `git hash-object` of the path equals the HEAD blob (`01878adb…`) and `git diff HEAD` is empty. Package tests / typecheck: none owed — the diff touches no `packages/**` file, so there is no ① dependency closure and no ② package suite; the whole-repo `pnpm lint` sweep is CI's. Changeset: `skip-changeset` applies. Both paths are outside every published package: 0 of the 69 non-private `package.json` manifests list `skills/`, `docs/adr` or a parent path in `files[]`; positive control — `RowLevelSecurityPolicySchema` is found in `packages/spec/dist/security/index.d.ts` (grep exit 0) and the schema's describe phrase "decided per operation across the applicable policies" in 9 spec dist files (exit 0); negative — the new skill sentence "chosen per operation across the applicable" is in no built output under `packages/` (exit 1). `docs/adr/**` is on the fast track (never published). ## Acceptance notes - The dispatch order's suggested ADR phrasing named "the `using` of the applicable insert-class policies" as the stand-in. The code is wider: `writeCheckPolicies` runs for `insert` and `update` alike (an `all` policy included), and when no applicable policy declares `check`, every applicable policy's `using` stands in for that operation. The landed text says "each applicable policy's `using`", matching `RowLevelSecurityPolicySchema.using` ("on an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in"). The triage note's "an insert policy's `using` stands in when no `check` is declared" is one instance of that rule, not the whole rule. - The example policy `org_isolation` (a hand-written `organization_id == current_user.organization_id` select policy) sits beside the file's own Multi-tenancy section ("⛔ never `single` + your own RLS") and duplicates the Layer 0 wall. Left as is: not this card, and the token ceiling was paid without touching it. Observation only, nothing to file. - `check-doc-formula-expressions` refuses (exit 3) on a fresh worktree until `@objectstack/formula` and `@objectstack/lint` are built; its refusal text names the fix. Not a finding. ## 维护者速读(草稿) **改了什么**:两处受管文本各改一段。ADR-0066「优先级/组合语义」第 3 项那一句,从「同一对象/操作的多条行策略 OR 合并(任一匹配即放行)」改为:读侧按 `using` OR 合并;写侧(insert/update)先按操作在适用策略中选出检查——有声明 `check` 的只用它们,否则每条适用策略的 `using` 顶上——再 OR 合并,逐行判定写出的每一行;并以符号锚引用 `compileFilter` 与 `writeCheckPolicies`。发布技能包 `objectstack-data` 的 RLS 段改写为四句:`using` 放行哪些行;`check` 逐行判定 insert/update 写出的每一行(数组插入与 `multi: true` 包含)、按操作选定、无 `check` 时 `using` 顶上;`select`/`delete` 策略上的非空 `check` 被拒;默认姿态(只在有策略适用时默认拒绝;无策略适用则不限制,租户墙照旧;update/delete 目标行在没有写侧 `using` 时受调用者的 `select` 策略约束)。示例块两行注释同步。 **为什么改**:这两段是 AI 写 RLS 策略时读的唯一说明,原文把 `check` 当成读过滤的对偶、且不写顶替规则,按它写出的策略与运行时真实执行不一致(NORTH-STAR 优先级规则 4:写给 AI 的文档与 skills 说错一句等于产品缺陷)。执行代码本身是对的,非受管文本已由 #20268 修正;本 PR 只改受管的两处,不动代码。 **风险与代价(含回滚)**:纯文本;不改 schema、不改运行时。`security.md` 的 token 上限为 2543、余量 0,新增内容以删除同文件重复句付账(2543 → 2541),上限未动、未挪内容到别的文件。29 个派生门禁全绿,ADR 符号锚经反向验证(改坏符号名即变红)。回滚即 revert 本 PR 的单个 commit,无迁移、无数据影响。 **席位意见**:(留空) **你要做的**:审阅两处措辞后在本 PR 上 Approve(Tier H);落地由席位执行。 --- _Generated by [Claude Code](https://claude.ai/code/session_01MjvgiFAmjHqsxy1XLiVYfH)_ Co-authored-by: objectstack-fleet[bot] <noreply@anthropic.com>
1 parent 9daced0 commit 2972097

2 files changed

Lines changed: 43 additions & 41 deletions

File tree

‎docs/adr/0066-unified-authorization-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Authorization resolves in a fixed order, adopted from shapes proven elsewhere
9090

9191
1. **AND-gates (hard prerequisites).** A resource's `requiredPermissions` (D3) and its `private` posture (D2) are prerequisites, not grants. The caller must clear every gate *before* any grant is consulted: missing a required capability, or lacking an explicit grant on a `private` object, **denies** regardless of how permissive the rest of the configuration is.
9292
2. **Grants union (most-permissive).** Within the gates, object-CRUD and field grants combine most-permissively across the caller's permission sets — any set that allows wins (the existing semantics).
93-
3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies for the same object/operation are OR-combined (any matching policy admits the row); the wildcard tenant-isolation policy AND-s on top as a global scope. The **superuser bypass** (D2: `viewAllRecords`/`modifyAllRecords`, gated by posture) short-circuits RLS for the object.
93+
3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies for the same object/operation are OR-combined: on a read, the applicable policies' `using` predicates OR-combine (any matching policy admits the row; `packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler.compileFilter`); on an insert or update, the check is chosen once per operation across the applicable policies — the declared `check` predicates when any declares one, else each applicable policy's `using` standing in (`packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies`, ADR-0058 D4) — and the chosen predicates OR-combine over every row written; the wildcard tenant-isolation policy AND-s on top as a global scope. The **superuser bypass** (D2: `viewAllRecords`/`modifyAllRecords`, gated by posture) short-circuits RLS for the object.
9494
4. **Explicit deny overrides (when introduced).** If/when a per-resource deny is added (Salesforce permission-set-group *muting*; see Future refinements) it sits at the top and overrides any union grant. Until then there is no implicit deny except the gates in (1) and fail-closed defaults (an applicable-but-uncompilable RLS policy denies).
9595

9696
## Open-core boundary

‎skills/objectstack-data/rules/security.md‎

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,39 +20,35 @@ export const salesUser = definePermissionSet({
2020
// defineStack({ permissions: [salesUser], ... })
2121
```
2222

23-
- **Stack key: `permissions`.** The collection is named for the metadata kind,
24-
not for the factory, so `definePermissionSet()` output goes into
25-
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
26-
load** — the top level is strict, so the stack fails with an
23+
- **Stack key: `permissions`** (named for the metadata kind, not the factory).
24+
`permissionSets:` is **refused at load** — the strict top level fails with an
2725
`Unrecognized key(s) on this stack definition` error naming the key, never a
28-
silent drop. `ObjectStackDefinitionSchema`
29-
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
30-
record; `objectstack-platform` lists every top-level key.
26+
silent drop; `ObjectStackDefinitionSchema`
27+
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of record.
3128
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
3229
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
3330
(super-user, bypass sharing).
3431
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
3532
- **`isDefault: true` = the `everyone` baseline (ADR-0090 D5).** It may carry app
3633
capabilities declared under `capabilities:` (`defineCapability`) and granted via
3734
`systemPermissions`; lint and boot refuse a platform capability or undeclared name there.
38-
- Combine with `enable.apiMethods` to also restrict the HTTP surface.
35+
- `enable.apiMethods` also restricts the HTTP surface.
3936

4037
## Assigning a permission set to a user
4138

4239
Declaring a set grants nobody anything — an assignment is **data**: one row in
4340
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
4441
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
4542
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
46-
half-open window checked at resolution time; `granted_by` is stamped by the
47-
gate on insert — never author it.
43+
half-open window; `granted_by` is stamped by the gate on insert — never author
44+
it.
4845

4946
⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
50-
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
51-
in that field matches nothing, raises no error, and silently grants nothing.
52-
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
53-
(ADR-0086 D5) — that id differs per environment, so resolve it first.
47+
`name`** — a `name` there matches nothing, raises no error, and silently grants
48+
nothing. Declared sets are upserted by `name` with a **generated** `id` on
49+
`kernel:ready` (ADR-0086 D5) — that id differs per environment, so resolve it first.
5450

55-
Assignment is therefore two calls, both `POST /api/v1/data/{object}`
51+
Assignment is two calls, both `POST /api/v1/data/{object}`
5652
(`…/query` with a QueryAST body for the read): look up the set's `id` in
5753
`sys_permission_set` by `name`, then insert
5854
`{ user_id, permission_set_id, organization_id }` into
@@ -69,10 +65,19 @@ enforcing code path (explaining another user needs `manage_users`).
6965

7066
The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
7167
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
72-
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
73-
`check` (write filter) **string** predicate. The compiler ANDs `using` into
74-
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
75-
re-reads the target row through the write filter before single-id `update`/`delete`.)
68+
CEL predicate on the object. Each policy's `using` and/or `check` is a **string**
69+
predicate. `using` admits rows: what a `select` policy lets the
70+
caller read, and the existing rows an `update`/`delete` policy lets it change
71+
or remove; on a read the applicable `using` OR-combine, then AND into the query.
72+
`check` is judged on every row an `insert`/`update` writes (array inserts and
73+
`multi: true` included; one failing row refuses the write), chosen per
74+
operation: when any applicable policy declares `check`, only those decide
75+
(OR-combined); else each applicable `using` stands in. A non-blank `check` on a
76+
`select`/`delete` policy is refused. Default deny, among the policies that
77+
apply: a row none admits is denied, an unevaluable policy fails closed; when
78+
none applies the policies restrict nothing (the tenant wall still applies) — an
79+
`update`/`delete` target with no write-class `using` is bounded by the caller's
80+
`select` policies.
7681

7782
```typescript
7883
// in a permission set (definePermissionSet)
@@ -81,8 +86,8 @@ rowLevelSecurity: [
8186
name: 'own_records',
8287
object: 'account', // REQUIRED per policy
8388
operation: 'all', // singular: select|insert|update|delete|all
84-
using: 'owner_id == current_user.id', // read scope
85-
check: 'owner_id == current_user.id', // write scope
89+
using: 'owner_id == current_user.id', // rows readable / targetable
90+
check: 'owner_id == current_user.id', // every row written
8691
},
8792
{
8893
name: 'org_isolation',
@@ -97,8 +102,8 @@ Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
97102
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
98103
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
99104
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
100-
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
101-
(emits a warning) but should be authored in CEL. The compiler resolves these
105+
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated**
106+
bridge (warns). The compiler resolves these
102107
`current_user.*` placeholders:
103108

104109
| Placeholder | Resolves to |
@@ -109,12 +114,10 @@ A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated*
109114
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
110115
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |
111116

112-
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
113-
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
114-
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
115-
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
116-
`sharingModel` (ADR-0056 D1)
117-
is the declarative way to set the org-wide default — prefer those over
117+
- Source: `node_modules/@objectstack/spec/src/security/rls.zod.ts` (policy
118+
shape, predicate grammar, `check` composition).
119+
- Prefer the built-in `member_default` owner scoping (`owner_only_writes` /
120+
`owner_only_deletes`) and the object's `sharingModel` (ADR-0056 D1) over
118121
hand-written policies for the common cases.
119122

120123
## Sensitive fields — `secret` type + `requiredPermissions`
@@ -135,11 +138,10 @@ fields: {
135138
}
136139
```
137140

138-
**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
139-
required to READ/EDIT the field. A field declaring `requiredPermissions` is
140-
**masked on read and denied on write** unless the caller holds ALL listed
141-
capabilities — an AND-gate that is strictest-wins over permission-set field
142-
grants. Enforced by plugin-security's FieldMasker.
141+
**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** A field
142+
declaring `requiredPermissions` is **masked on read and denied on write** unless
143+
the caller holds ALL listed capabilities — an AND-gate that is strictest-wins
144+
over permission-set field grants.
143145

144146
```typescript
145147
fields: {
@@ -190,8 +192,8 @@ none — e.g. identity tables a plugin writes via its own adapter
190192
crossing takes a *true platform admin* (the superuser bit **and** a
191193
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
192194
`studio.access`, `manage_users`) on one of those same postures. So an org
193-
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
194-
object nobody crosses — the admin sees 0 rows too.
195+
admin with the superuser bit stays org-scoped; on an ordinary tenant object
196+
nobody crosses (the admin sees 0 rows too).
195197

196198
**Recipe — env-global, admin-only object that admins can fully see:**
197199

@@ -208,7 +210,7 @@ requiredPermissions: ['manage_platform_settings'], // capability AND-gate → me
208210
> `member_default` baseline is **not** one of them: it is explicit-allow and
209211
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
210212
> object a tenant object, so the wall keeps denying the untagged rows and even a
211-
> platform admin sees nothing. The pair is the correct combo (admin sees all,
212-
> non-admins 403), and `requiredPermissions` is the half that holds however
213-
> permissive the caller's grants are — it is an AND-gate checked **before** the
214-
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.
213+
> platform admin sees nothing. The pair is the correct combo; `requiredPermissions`
214+
> is the half that holds however permissive the caller's grants are (an AND-gate
215+
> checked **before** the CRUD grant). Posture model: ADR-0066; tenant wall:
216+
> ADR-0095 D1.

0 commit comments

Comments
 (0)