Skip to content

Commit b531016

Browse files
feat(lint)!: membership key declarations, and the row policies that read them, are judged when authored (#25550)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJLr1A7TYy1k6cGE23eo9VQ)_ Closes #25234 Clause-②: no (narrowing) Metadata-protocol: narrowed PR 3 of 3 of ruling A on #25234 (ruling 6355640929, design 6344270438, landing plan 6370632236): the authoring refusals. PR 1 (#25513) declared `ObjectSchema.membershipKeys`; PR 2 (#25528) stages them per request, and every miss fails closed. This PR refuses, where the metadata is written, the declarations that cannot resolve and the policies that read a key in a way that matches no rows: refusals 4 to 7 and 11 to 13 of the design table, plus refusal 3 at the save door, and the runtime-gate registration for `object` writes. ## Refusals | # | What | Rule id | CLI | Save door | |:--|:--|:--|:--|:--| | 3 | one key declared on two objects | `membership-key-declared-twice` | yes (`defineStack` already refuses it in one stack) | yes, reading the live objects the written stack does not carry, outside the package closure too | | 4 | a key the registered resolver also owns | `membership-key-resolver-collision` | not judged (no resolver visible) | yes | | 5 | `userField` missing, or not a single-valued user reference (`user`, or `lookup` / `master_detail` to `sys_user`, not `multiple`) | `membership-key-user-field-invalid` | yes | yes | | 6 | `valueField` missing, or not a reference (`lookup`, `master_detail`, `user`) | `membership-key-value-field-invalid` | yes | yes | | 7 | a key's `filter` that does not lower, names a column the roster lacks, compares with a list-holding column, is refused by the engine's judge, or reads any `current_user` value | the existing `rls-predicate-*` ids, at `objects[N].membershipKeys.KEY.filter` | yes | yes | | 11 | a declared key tested against a column whose values can never be the key's (`buyer in current_user.portal_supplier_ids`) | new `rls-predicate-membership-key-target` | yes | yes | | 12 | `x in current_user.KEY` that no kernel key, declaration or registered resolver owns | `rls-predicate-unknown-user-variable` | unchanged, `unknowable` | yes | | 13 | a declared key under `==` | `rls-predicate-unenforceable` (the kernel list-key type fault) | yes | yes | Refusals 5 and 6 judge the roster object as merged with its extensions (`objectExtensions`, by priority). A column two packages write at the deciding priority is left unjudged. Refusal 11 reads the key's target from the declaration's `valueField` reference, only when exactly one object declares the key. It accepts the column that references that target, and `id` on the target object itself. ## The save door - `validateMembershipKeyDeclarations` is a new gating rule, `CLI_AND_RUNTIME`, `runtimeTypes: ['object']`. - `validateRlsPredicateEnforceability` now declares `runtimeTypes: ['permission', 'object']`. On an object write it judges the object's key filters and `defaultRowScope`. Through the differential gate, it also refuses an object write that drops a field or a key a stored policy reads. A stored policy that already failed is not blamed on the write. - **The host fact.** `assertRuntimeAuthoringRules` reads the registered resolver's `keys` per publish from `RLS_MEMBERSHIP_RESOLVER_SERVICE`, `[]` when none is registered (`protocol.ts#rlsMembershipResolverKeys`). `runRuntimeAuthoringRules` takes them as `rlsMembershipResolverKeys` and hands the rules a `membershipKeyOwners` context: the resolver's keys, and which live objects declare which key. The declarations are read from the un-narrowed `context.objects`, so the package closure cannot hide a key. The written stack is authoritative for the objects it carries. With no service registry, or a resolver whose `keys` is not a list of strings, no ownership is judged, as on the CLI. ## `os explain` New entries for the four `membership-key-*` ids and `rls-predicate-membership-key-target`. Each says how a key resolves and that every miss fails closed. `rls-predicate-unenforceable` names the filter path. `rls-predicate-unknown-user-variable` gains three paragraphs: a declared key under `==`; the CLI-versus-save-door split for an unowned key in a membership position; and the caller-free filter. All verdicts and hints are one line of at most 200 characters (pinned). ## Pins moved, each with its reason in the diff - `runtime-gate.object-writes.test.ts`: the object-door roster and the clean-write `rulesRun` gain the two rules, as ruled joins. - `validate-rls-predicate-enforceability.engine-judge.test.ts`: `runtimeTypes` is `['permission', 'object']`. - `validate-rls-predicate-enforceability.test.ts`: the `os explain` text the verdict moved to now says "not reported by `os validate`" where it said "never reported", because the save door now reports it. - `packages/cli/test/rls-policy-authoring-admission.test.ts` (real `ObjectQL` host): the app-staged `current_user.holidays` cases register a resolver owning `holidays`, which is how an app stages a key. A new case pins refusal 12 end to end: with no resolver, the CLI accepts and the save door answers 422 with `rls-predicate-unknown-user-variable`. ## Tests - `validate-membership-key-declarations.test.ts`: 17, refusals 3 to 6, both doors, merged-object judging, an unjudged priority tie, `os explain`, verdict length. - `validate-rls-predicate-enforceability.membership-keys.test.ts`: 24, refusals 7, 11, 12 and 13, each with a CONTROL, both doors, the object write that drops a key a stored policy reads (refused) or keeps it (published), `os explain`, verdict length. - `protocol.runtime-authoring-gate.test.ts`: 4 host cases. No resolver: refused, nothing written. A resolver owning the key: published. A resolver owning other keys: refused. No registry, a resolver without `keys`, or non-string `keys`: published. **Ablations, each restored byte-identically from a saved copy:** - Refusal 12 arm off: 3 red. Refusal 11 arm off: 7 red. Refusal 13 swap off: 2 red. Refusal 7 caller-read check off: 1 red. Refusal 7 judge pass off: 6 red. Stack authority over the stored declarations off: 1 red. - Each of the six `findings.push` sites in the declarations rule disabled in turn: 1, 6, 2, 1, 4 and 3 red. - The host's resolver read replaced by `undefined`: the two refusing host cases red, the two publishing ones green. **Suites:** - `@objectstack/lint`: 158 files, 7237 passed. `tsc --noEmit` exit 0. dts build with `OS_SKIP_DTS` unset: 6/6 declaration files. - `@objectstack/metadata-protocol`: 261 files, 21457 passed, 19 skipped, before the 4 host cases; those 4 pass. `tsc --noEmit` exit 0. - `@objectstack/cli` `rls-policy-authoring-admission.test.ts`: 40 passed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 71 families on this head (1,972 changed lines, under the human-merge threshold). All 71 were run one at a time under `os-verify-lock.sh`, exit codes captured before any pipe, after a dts build of the `@objectstack/cli` closure; so were 58 more the longer listing names. All exit 0 but the following. - One real finding, fixed in `d16c4a71`: `check:docs-transcript-drift` found four docs transcripts printing "author-time rules (51)". The `validate` registry now derives 52 (the declarations rule), so the four lines read 52. - Four are PR-context guards that judge nothing locally (exit 1 or 2 with "NOT WIRED" / "NOT MEASURED"): `check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths` and `check-governed-queue-guard`. CI runs them with the PR. `check-partof-closing-keyword` was also run with this body as `PR_BODY` and passes. **Changeset** `.changeset/25234-membership-key-authoring-refusals.md`: `minor` on `@objectstack/lint`, `patch` on `@objectstack/metadata-protocol`, BREAKING with FROM, TO, Unchanged and Remedy, ADR-0087 `not-required (no-migration-prescription)`. `pnpm check:adr-0087-registration` passes. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45f9962 commit b531016

20 files changed

Lines changed: 1868 additions & 122 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(lint)!: a membership key, and a row policy that reads one, are judged when authored, at the CLI and at the object and permission save doors
7+
8+
Clause-②: no (narrowing: `os validate`, `os build`, `os lint` and the object and permission save doors refuse a `membershipKeys` declaration that cannot resolve and a row policy that reads a membership key in a way that matches no rows; the RLS rule now also judges object writes)
9+
Metadata-protocol: narrowed
10+
11+
`ObjectSchema.membershipKeys` names a `current_user.KEY` set: the distinct `valueField` values of the roster rows whose `userField` is the signed-in user and on which `filter` holds. The security plugin resolves it per request, and every miss fails closed, so a declaration that cannot resolve, or a policy that reads a key in a position it cannot match, silently grants nothing. Those are now refused where they are written.
12+
13+
**The new rule, `validateMembershipKeyDeclarations`** (gating, every command, and the runtime publish gate for `object` writes), with four ids:
14+
15+
- `membership-key-user-field-invalid`: `userField` is not a column of the roster object, or is not a single-valued user reference (a `user` field, or a `lookup` / `master_detail` to `sys_user`, not `multiple`). The column is judged on the object as merged with its extensions.
16+
- `membership-key-value-field-invalid`: `valueField` is not a column of the roster object, or is not a reference (`lookup`, `master_detail` or `user`).
17+
- `membership-key-declared-twice`: one key declared on two objects. At the save door this reads the live objects the written stack does not carry, outside the package closure too.
18+
- `membership-key-resolver-collision`: a key the registered membership resolver (ADR-0105 D11) also owns. Judged at the save door only, which holds the resolver's `keys`; the CLI cannot see a resolver.
19+
20+
**`validateRlsPredicateEnforceability` learns membership keys**, and now runs for `object` writes at the save door beside `permission` writes:
21+
22+
- A key's `filter` is judged like an RLS clause, with the roster object as its target, at `objects[N].membershipKeys.KEY.filter`. A filter that does not lower, names a column the object lacks, compares with a list-holding column, or is refused by the engine's judge reports the existing `rls-predicate-*` id. A filter reading any `current_user` value reports `rls-predicate-unknown-user-variable`: a key is resolved with no caller.
23+
- New id `rls-predicate-membership-key-target`: a declared key tested against a column whose values can never be the key's (`buyer in current_user.portal_supplier_ids`, where the key holds `srm_supplier` ids and `buyer` is a user). The referencing column, and `id` on the target object itself, are accepted.
24+
- A declared key compared with `==` reports `rls-predicate-unenforceable`, as a kernel list key always has: a key holds a list.
25+
- At the save door only, `x in current_user.KEY` where no kernel key, membership key declaration or registered resolver owns `KEY` reports `rls-predicate-unknown-user-variable`. The CLI keeps accepting it, because a self-hosted resolver it cannot see may own the key.
26+
27+
**`@objectstack/metadata-protocol`:** the publish gate reads the registered membership resolver's `keys` per publish (`RLS_MEMBERSHIP_RESOLVER_SERVICE`), `[]` when none is registered, and hands them to the rules above. A host with no service registry, or a resolver whose `keys` is not a list of strings, judges no ownership, as the CLI does.
28+
29+
**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. `membershipKeys` itself is unreleased, so the declaration rule narrows nothing shipped; what follows does.
30+
31+
- **FROM:** a permission write whose policy reads `x in current_user.KEY` that nothing owns answered 200, and the policy matched no rows at runtime. An object write was not judged by the RLS rule, so an object whose `defaultRowScope` the rule refuses on the CLI published, and so did an object write that dropped a field or a membership key a stored policy reads.
32+
- **TO:** in publish mode each of those answers `422 INVALID_METADATA` with the issue located at the policy, the default row scope, or the key. The gate is differential: a stored policy that was already failing is not blamed on the write, and a write is refused only for a finding it adds.
33+
- **Unchanged:** the CLI verdict on an unowned key in a membership position (`unknowable`); a policy reading a key a resolver registered at runtime owns; drafts (`mode: 'draft'`), which are never gated; `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, which still turns a refusal into a logged write; stored rows, which are not read, migrated or refused until next saved; and every public export and signature.
34+
- **Remedy:** declare the key on its roster object's `membershipKeys`, or register the resolver that stages it, before publishing the policy; test a key against the column that references the key's target; use `in`, not `==`; keep a key's `filter` to the roster object's own columns. An object write refused for a stored policy is fixed by keeping the field or key that policy reads, or by changing the policy first.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) Refusals at the CLI and at the object and permission save doors of membership key declarations that cannot resolve and of row policies that read a membership key in a position that matches no rows, plus the RLS rule's crossing to object writes. No authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted; the repair is the author's choice of declaration, resolver, column or operator, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these verdicts (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
758758
→ Normalizing stack definition...
759759
→ Lowering inline handlers...
760760
→ Validating protocol compliance...
761-
→ Running author-time rules (51)...
761+
→ Running author-time rules (52)...
762762
→ Checking that every required capability has a provider installable in this edition...
763763
→ Collecting package docs (ADR-0046)... 0 collected
764764
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,9 @@ orthogonal to both, and no cell here can carry it; it is written out in
477477
| Security vocabulary freeze (ADR-0090 D3 — the reserved word, replaced by `permission_set` / `position` / `business_unit`) | ✓ | ✓ | ✓ | ✓ᵒᵖᵉᵇᵖᵒᵃ |
478478
| Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | — |
479479
| Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ |
480-
| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698), row-level-security predicates (#4983) | ✓ | ✓ | ✓ | — |
480+
| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698) | ✓ | ✓ | ✓ | — |
481+
| Row-level-security predicates (#4983) — a permission set's policies, an object's `defaultRowScope` and a membership key's `filter`; at the save door also a membership key nothing owns (#25234) | ✓ | ✓ | ✓ | ✓ᵖᵉᵒ |
482+
| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ |
481483
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
482484
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
483485
| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
@@ -543,7 +545,11 @@ is answered by an `object` write at all — and each is held by something
543545
different: the door does not accept `sharing_rule` as a type yet, whereas
544546
`permissions` has been in the snapshot since #8309 and only the declaration is
545547
missing. The freeze kept one id whole by staying behind and then crossing whole;
546-
here four ids stopped agreeing, and one row cannot say two things. **When the next crossing lands on
548+
here four ids stopped agreeing, and one row cannot say two things. The RLS
549+
rule has since crossed on its own: to `permission` writes in #20158, and to
550+
`object` writes in #25234, where it judges an object's `defaultRowScope` and
551+
its membership keys' `filter`s and refuses an object write that breaks a stored
552+
policy, so it now has a row of its own. **When the next crossing lands on
547553
a shared row, check the rule ids before the cell: if they disagree, split the
548554
row by the collection each id reads, and neither `✓` nor `—` has to lie.**
549555

@@ -735,7 +741,7 @@ A clean run walks the registry and reports timing:
735741
Config: /path/to/support-desk/objectstack.config.ts
736742
Load time: 21ms
737743
→ Validating against ObjectStack Protocol...
738-
→ Running author-time rules (51)...
744+
→ Running author-time rules (52)...
739745
→ Checking that every required capability has a provider installable in this edition...
740746
→ Checking package docs (ADR-0046)...
741747

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ visible: 'status != "resolved"'
305305
◆ Validate
306306
────────────────────────────────────────
307307
→ Validating against ObjectStack Protocol...
308-
→ Running author-time rules (51)...
308+
→ Running author-time rules (52)...
309309
310310
✗ Author-time rules failed (1 issue)
311311
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ objectstack validate
390390
────────────────────────────────────────
391391
→ Loading configuration...
392392
→ Validating against ObjectStack Protocol...
393-
→ Running author-time rules (51)...
393+
→ Running author-time rules (52)...
394394
→ Checking that every required capability has a provider installable in this edition...
395395
→ Checking package docs (ADR-0046)...
396396

‎packages/cli/test/rls-policy-authoring-admission.test.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import { afterEach, describe, expect, it } from 'vitest';
3636
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
37+
import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts';
3738
import { runAuthoringRules, type AuthoringFinding } from '@objectstack/lint';
3839
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
3940
import { SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
@@ -109,7 +110,7 @@ afterEach(async () => {
109110
});
110111

111112
/** A live engine holding the same objects, as the protocol's host — real tables, real DDL. */
112-
async function runtimeHost() {
113+
async function runtimeHost(services: () => Map<string, unknown> = () => new Map()) {
113114
const engine = new ObjectQL({ logger: createLogger({ level: 'silent' }) });
114115
liveEngines.push(engine);
115116
engine.registry.logLevel = 'silent';
@@ -119,7 +120,7 @@ async function runtimeHost() {
119120
engine.registry.registerObject(structuredClone(obj) as never, 'objectstack-test');
120121
}
121122
await engine.syncSchemas();
122-
return { engine, protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') };
123+
return { engine, protocol: new ObjectStackProtocolImplementation(engine, services as () => Map<string, any>, 'env_test') };
123124
}
124125

125126
interface SaveOutcome {
@@ -129,8 +130,12 @@ interface SaveOutcome {
129130
issues: Array<{ rule: string; path: string; message: string }>;
130131
}
131132

132-
async function runtimeDoor(using: string, set = permissionSet(using)): Promise<SaveOutcome> {
133-
const { protocol } = await runtimeHost();
133+
async function runtimeDoor(
134+
using: string,
135+
set = permissionSet(using),
136+
services?: () => Map<string, unknown>,
137+
): Promise<SaveOutcome> {
138+
const { protocol } = await runtimeHost(services);
134139
try {
135140
await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: set });
136141
return { accepted: true, issues: [] };
@@ -222,20 +227,39 @@ describe('RLS read-scope admission at authoring time — fifteen classes, two do
222227
});
223228
224229
describe('the judge pass binds an app-staged membership key to [] (#20158)', () => {
230+
// [#25234] The app stages `holidays` through the registered membership
231+
// resolver (ADR-0105 D11). The runtime door reads that resolver's `keys`, so
232+
// the key is owned there only while one is registered — the last case below.
233+
const holidaysResolver = () =>
234+
new Map<string, unknown>([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { keys: ['holidays'], resolve: async () => ({}) }]]);
235+
225236
it('`close_date in current_user.holidays` is ACCEPTED at both doors — the probe string is not a date, the app data may be', async () => {
226237
const using = 'close_date in current_user.holidays';
227238
expect(cliDoor(using)).toEqual([]);
228-
expect(await runtimeDoor(using)).toEqual({ accepted: true, issues: [] });
239+
expect(await runtimeDoor(using, undefined, holidaysResolver)).toEqual({ accepted: true, issues: [] });
229240
});
230241
231242
it('the binding does not switch the judge off: the same clause beside an engine-refused arm is REFUSED at both', async () => {
232243
const using = "close_date in current_user.holidays && amount.startsWith('5')";
233244
const cli = cliDoor(using);
234-
const saved = await runtimeDoor(using);
245+
const saved = await runtimeDoor(using, undefined, holidaysResolver);
235246
expect(cli.map((f) => f.rule)).toEqual([UNENFORCEABLE]);
236247
expect(cli[0].message).toContain('(INVALID_FILTER / 400): ');
237248
expect(saved.issues.map((i) => i.message)).toEqual([cli[0].message]);
238249
});
250+
251+
it('[#25234] with no resolver registered, nothing stages the key: the runtime door refuses what the CLI cannot judge', async () => {
252+
const using = 'close_date in current_user.holidays';
253+
expect(cliDoor(using)).toEqual([]);
254+
const saved = await runtimeDoor(using);
255+
expect({ accepted: saved.accepted, code: saved.code, status: saved.status }).toEqual({
256+
accepted: false, code: 'INVALID_METADATA', status: 422,
257+
});
258+
expect(saved.issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([
259+
{ rule: 'rls-predicate-unknown-user-variable', path: 'permissions.sales.rowLevelSecurity[0].using' },
260+
]);
261+
expect(saved.issues[0].message).toContain('`current_user.holidays` as a membership set');
262+
});
239263
});
240264
241265
/**

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ import { validateSecurityPosture, validateSecurityRoleWord } from './validate-se
136136
import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js';
137137
import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js';
138138
import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js';
139+
import {
140+
validateMembershipKeyDeclarations,
141+
type MembershipKeyOwners,
142+
} from './validate-membership-key-declarations.js';
139143
import { validateRuleCompilability } from './validate-rule-compilability.js';
140144
import { validateRuleSchemaFormats } from './validate-rule-schema-formats.js';
141145
import { validateActionLocations } from './validate-action-locations.js';
@@ -399,6 +403,21 @@ export interface AuthoringRuleContext {
399403
* flow whose trigger object is outside it instead of advising.
400404
*/
401405
packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] };
406+
/**
407+
* [#25234] The deployment's membership-key owners: the keys the registered
408+
* `rls-membership-resolver` declares (`IRlsMembershipResolver.keys`, empty
409+
* when none is registered), and every key the WHOLE live object collection
410+
* declares (`ObjectSchema.membershipKeys`), read before the package-closure
411+
* narrowing because a key's namespace is the deployment's.
412+
*
413+
* Set only by the runtime publish gate, and only when its host states the
414+
* resolver's keys; ABSENT on the three CLI commands, where a self-hosted
415+
* resolver may own any key. Read by `validateMembershipKeyDeclarations`
416+
* (a key both sources own; a key two live objects declare) and
417+
* `validateRlsPredicateEnforceability` (a key in a membership position that
418+
* nobody owns).
419+
*/
420+
membershipKeyOwners?: MembershipKeyOwners;
402421
}
403422

404423
export interface AuthoringRule {
@@ -2263,8 +2282,36 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
22632282
commands: ALL,
22642283
source: 'packages/lint/src/validate-rls-predicate-enforceability.ts',
22652284
surfaces: CLI_AND_RUNTIME,
2266-
runtimeTypes: ['permission'],
2267-
run: (stack, ctx) => validateRlsPredicateEnforceability(stack, { judgeFilter: ctx.judgeFilter }),
2285+
// [#25234] `object` too: an object carries two predicates this rule judges,
2286+
// its `defaultRowScope` (#24452) and its membership keys' `filter`, and a
2287+
// hosted author has no other door for either. The differential charges an
2288+
// object write only with what it adds: a permission set's finding that
2289+
// the write's own declaration resolves, or a filter it writes.
2290+
runtimeTypes: ['permission', 'object'],
2291+
run: (stack, ctx) =>
2292+
validateRlsPredicateEnforceability(stack, {
2293+
judgeFilter: ctx.judgeFilter,
2294+
membershipKeyOwners: ctx.membershipKeyOwners,
2295+
}),
2296+
},
2297+
// [#25234 ruling A, refusals 3 to 6] A membership key's declaration
2298+
// (`ObjectSchema.membershipKeys`): its `userField` and `valueField` judged on
2299+
// the merged object, one key on two objects, and a key the registered
2300+
// resolver also owns. The security plugin resolves each declaration per
2301+
// request and fails closed on every one of these, so each is a key that reads
2302+
// as a grant and matches no rows. Across the runtime publish gate for
2303+
// `object` writes, the one door a hosted author has; the two deployment
2304+
// facts arrive as `ctx.membershipKeyOwners`, which the CLI does not have.
2305+
{
2306+
name: 'validateMembershipKeyDeclarations',
2307+
tier: 'gating',
2308+
input: 'parsed',
2309+
commands: ALL,
2310+
source: 'packages/lint/src/validate-membership-key-declarations.ts',
2311+
surfaces: CLI_AND_RUNTIME,
2312+
runtimeTypes: ['object'],
2313+
run: (stack, ctx) =>
2314+
validateMembershipKeyDeclarations(stack, { membershipKeyOwners: ctx.membershipKeyOwners }),
22682315
},
22692316
// #4762 — the same "declared but enforces nothing" question, for the two
22702317
// STATIC artifacts an object validation rule carries. A `format` rule's

‎packages/lint/src/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,12 +383,29 @@ export {
383383
RLS_PREDICATE_OVER_BUDGET,
384384
RLS_PREDICATE_UNKNOWN_FIELD,
385385
RLS_PREDICATE_UNKNOWN_USER_VARIABLE,
386+
RLS_PREDICATE_MEMBERSHIP_KEY_TARGET,
386387
} from './validate-rls-predicate-enforceability.js';
387388
export type {
388389
RlsPredicateFinding,
389390
RlsPredicateSeverity,
390391
} from './validate-rls-predicate-enforceability.js';
391392

393+
// [#25234 ruling A] A membership key's DECLARATION (`ObjectSchema.membershipKeys`):
394+
// its `userField` and `valueField` on the merged object, one key on two objects,
395+
// and a key the registered resolver also owns. Each one is a key the security
396+
// plugin cannot resolve, so every policy reading it matches no rows.
397+
export {
398+
validateMembershipKeyDeclarations,
399+
MEMBERSHIP_KEY_USER_FIELD_INVALID,
400+
MEMBERSHIP_KEY_VALUE_FIELD_INVALID,
401+
MEMBERSHIP_KEY_DECLARED_TWICE,
402+
MEMBERSHIP_KEY_RESOLVER_COLLISION,
403+
} from './validate-membership-key-declarations.js';
404+
export type {
405+
MembershipKeyFinding,
406+
MembershipKeyOwners,
407+
} from './validate-membership-key-declarations.js';
408+
392409
export {
393410
validateDashboardActionRefs,
394411
DASHBOARD_ACTION_TARGET_UNDEFINED,

0 commit comments

Comments
 (0)