diff --git a/.changeset/25234-membership-key-authoring-refusals.md b/.changeset/25234-membership-key-authoring-refusals.md new file mode 100644 index 00000000000..0d71ba2f1df --- /dev/null +++ b/.changeset/25234-membership-key-authoring-refusals.md @@ -0,0 +1,36 @@ +--- +'@objectstack/lint': minor +'@objectstack/metadata-protocol': patch +--- + +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 + +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) +Metadata-protocol: narrowed + +`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. + +**The new rule, `validateMembershipKeyDeclarations`** (gating, every command, and the runtime publish gate for `object` writes), with four ids: + +- `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. +- `membership-key-value-field-invalid`: `valueField` is not a column of the roster object, or is not a reference (`lookup`, `master_detail` or `user`). +- `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. +- `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. + +**`validateRlsPredicateEnforceability` learns membership keys**, and now runs for `object` writes at the save door beside `permission` writes: + +- 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. +- 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. +- A declared key compared with `==` reports `rls-predicate-unenforceable`, as a kernel list key always has: a key holds a list. +- 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. + +**`@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. + +**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. + +- **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. +- **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. +- **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. +- **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. + + diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 1550991797e..f70fa93de47 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (51)... + → Running author-time rules (52)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 1900358136c..4dc2043cf91 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -477,7 +477,9 @@ orthogonal to both, and no cell here can carry it; it is written out in | Security vocabulary freeze (ADR-0090 D3 — the reserved word, replaced by `permission_set` / `position` / `business_unit`) | ✓ | ✓ | ✓ | ✓ᵒᵖᵉᵇᵖᵒᵃ | | Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | — | | Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ | -| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698), row-level-security predicates (#4983) | ✓ | ✓ | ✓ | — | +| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698) | ✓ | ✓ | ✓ | — | +| 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) | ✓ | ✓ | ✓ | ✓ᵖᵉᵒ | +| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ | | Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ | | Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ | | Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ | @@ -543,7 +545,11 @@ is answered by an `object` write at all — and each is held by something different: the door does not accept `sharing_rule` as a type yet, whereas `permissions` has been in the snapshot since #8309 and only the declaration is missing. The freeze kept one id whole by staying behind and then crossing whole; -here four ids stopped agreeing, and one row cannot say two things. **When the next crossing lands on +here four ids stopped agreeing, and one row cannot say two things. The RLS +rule has since crossed on its own: to `permission` writes in #20158, and to +`object` writes in #25234, where it judges an object's `defaultRowScope` and +its membership keys' `filter`s and refuses an object write that breaks a stored +policy, so it now has a row of its own. **When the next crossing lands on a shared row, check the rule ids before the cell: if they disagree, split the row by the collection each id reads, and neither `✓` nor `—` has to lie.** @@ -735,7 +741,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (51)... + → Running author-time rules (52)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 9ee4a6b60e8..1d28f9f23b6 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (51)... + → Running author-time rules (52)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 39b15dfe5b1..7bf7de254a7 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -390,7 +390,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (51)... + → Running author-time rules (52)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/packages/cli/test/rls-policy-authoring-admission.test.ts b/packages/cli/test/rls-policy-authoring-admission.test.ts index 5a6c202a66e..9fe07e24e6a 100644 --- a/packages/cli/test/rls-policy-authoring-admission.test.ts +++ b/packages/cli/test/rls-policy-authoring-admission.test.ts @@ -34,6 +34,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts'; import { runAuthoringRules, type AuthoringFinding } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; @@ -109,7 +110,7 @@ afterEach(async () => { }); /** A live engine holding the same objects, as the protocol's host — real tables, real DDL. */ -async function runtimeHost() { +async function runtimeHost(services: () => Map = () => new Map()) { const engine = new ObjectQL({ logger: createLogger({ level: 'silent' }) }); liveEngines.push(engine); engine.registry.logLevel = 'silent'; @@ -119,7 +120,7 @@ async function runtimeHost() { engine.registry.registerObject(structuredClone(obj) as never, 'objectstack-test'); } await engine.syncSchemas(); - return { engine, protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') }; + return { engine, protocol: new ObjectStackProtocolImplementation(engine, services as () => Map, 'env_test') }; } interface SaveOutcome { @@ -129,8 +130,12 @@ interface SaveOutcome { issues: Array<{ rule: string; path: string; message: string }>; } -async function runtimeDoor(using: string, set = permissionSet(using)): Promise { - const { protocol } = await runtimeHost(); +async function runtimeDoor( + using: string, + set = permissionSet(using), + services?: () => Map, +): Promise { + const { protocol } = await runtimeHost(services); try { await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: set }); return { accepted: true, issues: [] }; @@ -222,20 +227,39 @@ describe('RLS read-scope admission at authoring time — fifteen classes, two do }); describe('the judge pass binds an app-staged membership key to [] (#20158)', () => { + // [#25234] The app stages `holidays` through the registered membership + // resolver (ADR-0105 D11). The runtime door reads that resolver's `keys`, so + // the key is owned there only while one is registered — the last case below. + const holidaysResolver = () => + new Map([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { keys: ['holidays'], resolve: async () => ({}) }]]); + 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 () => { const using = 'close_date in current_user.holidays'; expect(cliDoor(using)).toEqual([]); - expect(await runtimeDoor(using)).toEqual({ accepted: true, issues: [] }); + expect(await runtimeDoor(using, undefined, holidaysResolver)).toEqual({ accepted: true, issues: [] }); }); it('the binding does not switch the judge off: the same clause beside an engine-refused arm is REFUSED at both', async () => { const using = "close_date in current_user.holidays && amount.startsWith('5')"; const cli = cliDoor(using); - const saved = await runtimeDoor(using); + const saved = await runtimeDoor(using, undefined, holidaysResolver); expect(cli.map((f) => f.rule)).toEqual([UNENFORCEABLE]); expect(cli[0].message).toContain('(INVALID_FILTER / 400): '); expect(saved.issues.map((i) => i.message)).toEqual([cli[0].message]); }); + + it('[#25234] with no resolver registered, nothing stages the key: the runtime door refuses what the CLI cannot judge', async () => { + const using = 'close_date in current_user.holidays'; + expect(cliDoor(using)).toEqual([]); + const saved = await runtimeDoor(using); + expect({ accepted: saved.accepted, code: saved.code, status: saved.status }).toEqual({ + accepted: false, code: 'INVALID_METADATA', status: 422, + }); + expect(saved.issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([ + { rule: 'rls-predicate-unknown-user-variable', path: 'permissions.sales.rowLevelSecurity[0].using' }, + ]); + expect(saved.issues[0].message).toContain('`current_user.holidays` as a membership set'); + }); }); /** diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 062d8f43fff..7671ba5376e 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -136,6 +136,10 @@ import { validateSecurityPosture, validateSecurityRoleWord } from './validate-se import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js'; +import { + validateMembershipKeyDeclarations, + type MembershipKeyOwners, +} from './validate-membership-key-declarations.js'; import { validateRuleCompilability } from './validate-rule-compilability.js'; import { validateRuleSchemaFormats } from './validate-rule-schema-formats.js'; import { validateActionLocations } from './validate-action-locations.js'; @@ -399,6 +403,21 @@ export interface AuthoringRuleContext { * flow whose trigger object is outside it instead of advising. */ packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] }; + /** + * [#25234] The deployment's membership-key owners: the keys the registered + * `rls-membership-resolver` declares (`IRlsMembershipResolver.keys`, empty + * when none is registered), and every key the WHOLE live object collection + * declares (`ObjectSchema.membershipKeys`), read before the package-closure + * narrowing because a key's namespace is the deployment's. + * + * Set only by the runtime publish gate, and only when its host states the + * resolver's keys; ABSENT on the three CLI commands, where a self-hosted + * resolver may own any key. Read by `validateMembershipKeyDeclarations` + * (a key both sources own; a key two live objects declare) and + * `validateRlsPredicateEnforceability` (a key in a membership position that + * nobody owns). + */ + membershipKeyOwners?: MembershipKeyOwners; } export interface AuthoringRule { @@ -2263,8 +2282,36 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-rls-predicate-enforceability.ts', surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['permission'], - run: (stack, ctx) => validateRlsPredicateEnforceability(stack, { judgeFilter: ctx.judgeFilter }), + // [#25234] `object` too: an object carries two predicates this rule judges, + // its `defaultRowScope` (#24452) and its membership keys' `filter`, and a + // hosted author has no other door for either. The differential charges an + // object write only with what it adds: a permission set's finding that + // the write's own declaration resolves, or a filter it writes. + runtimeTypes: ['permission', 'object'], + run: (stack, ctx) => + validateRlsPredicateEnforceability(stack, { + judgeFilter: ctx.judgeFilter, + membershipKeyOwners: ctx.membershipKeyOwners, + }), + }, + // [#25234 ruling A, refusals 3 to 6] A membership key's declaration + // (`ObjectSchema.membershipKeys`): its `userField` and `valueField` judged on + // the merged object, one key on two objects, and a key the registered + // resolver also owns. The security plugin resolves each declaration per + // request and fails closed on every one of these, so each is a key that reads + // as a grant and matches no rows. Across the runtime publish gate for + // `object` writes, the one door a hosted author has; the two deployment + // facts arrive as `ctx.membershipKeyOwners`, which the CLI does not have. + { + name: 'validateMembershipKeyDeclarations', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-membership-key-declarations.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], + run: (stack, ctx) => + validateMembershipKeyDeclarations(stack, { membershipKeyOwners: ctx.membershipKeyOwners }), }, // #4762 — the same "declared but enforces nothing" question, for the two // STATIC artifacts an object validation rule carries. A `format` rule's diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d3edbd799a9..3682a92d1d5 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -383,12 +383,29 @@ export { RLS_PREDICATE_OVER_BUDGET, RLS_PREDICATE_UNKNOWN_FIELD, RLS_PREDICATE_UNKNOWN_USER_VARIABLE, + RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, } from './validate-rls-predicate-enforceability.js'; export type { RlsPredicateFinding, RlsPredicateSeverity, } from './validate-rls-predicate-enforceability.js'; +// [#25234 ruling A] A membership key's DECLARATION (`ObjectSchema.membershipKeys`): +// its `userField` and `valueField` on the merged object, one key on two objects, +// and a key the registered resolver also owns. Each one is a key the security +// plugin cannot resolve, so every policy reading it matches no rows. +export { + validateMembershipKeyDeclarations, + MEMBERSHIP_KEY_USER_FIELD_INVALID, + MEMBERSHIP_KEY_VALUE_FIELD_INVALID, + MEMBERSHIP_KEY_DECLARED_TWICE, + MEMBERSHIP_KEY_RESOLVER_COLLISION, +} from './validate-membership-key-declarations.js'; +export type { + MembershipKeyFinding, + MembershipKeyOwners, +} from './validate-membership-key-declarations.js'; + export { validateDashboardActionRefs, DASHBOARD_ACTION_TARGET_UNDEFINED, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index c4ab22eb5fe..4a2d09e775c 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1351,7 +1351,9 @@ const RLS_PREDICATE_UNENFORCEABLE_EXPLANATION: RuleExplanation = { '`==` / `!=` or the bare `current_user` root used as a value is outside it, and the compiler refuses it. ' + 'The finding quotes the verdict half of the compiler\'s own refusal. It judges both places a row policy ' + 'is declared, which the runtime compiles alike: a permission set\'s `rowLevelSecurity` entry and an ' + - 'object\'s `defaultRowScope`, whose `path` starts at `objects[N].defaultRowScope`.', + 'object\'s `defaultRowScope`, whose `path` starts at `objects[N].defaultRowScope`. It judges a ' + + 'membership key\'s `filter` the same way (`objects[N].membershipKeys.KEY.filter`), with the declaring ' + + 'object as its target: see `os explain rls-predicate-membership-key-target` for what a membership key is.', 'The lowerable subset is: `==` `!=` `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and the ' + 'string methods `startsWith` / `endsWith` / `contains` — over SINGLE-column field paths (ADR-0058 D2), ' + 'compared against a literal or a `current_user.*` value. Three traps in particular: (1) a function call — ' + @@ -1470,10 +1472,12 @@ const RLS_PREDICATE_UNKNOWN_USER_VARIABLE_EXPLANATION: RuleExplanation = { 'outside that list in a scalar position — compared with `==` / `!=` / `<` / `>` or handed to a string ' + 'method — can therefore never be supplied by any request.', 'Of the kernel keys, `current_user.organization_id` is the tenant, `current_user.id` the acting user ' + - 'and `current_user.email` their unique address. A key meant as an app-resolved set must be staged ' + - 'into `ExecutionContext.rlsMembership` by an `IRlsMembershipResolver` that DECLARES it, and can then ' + - 'only be tested with `in` (`FIELD in current_user.KEY`), never compared with `==`: the runtime stages ' + - 'membership sets as arrays and never as scalars.', + 'and `current_user.email` their unique address. A key meant as an app-resolved set is either declared ' + + 'on its roster object (`membershipKeys`) or staged into `ExecutionContext.rlsMembership` by an ' + + '`IRlsMembershipResolver` that DECLARES it in its `keys`, and can then only be tested with `in` ' + + '(`FIELD in current_user.KEY`), never compared with `==`: the runtime stages membership sets as arrays ' + + 'and never as scalars. A declared key compared with `==` is `rls-predicate-unenforceable`, as a kernel ' + + 'list key is: the rule knows it holds a list.', 'The compiler answers `unresolved-variable` for it in EVERY position — including under `!` and in a ' + 'trailing `||` arm — so `RLSCompiler` DROPS the policy at request time, and one WARN line is the only ' + 'signal.', @@ -1481,8 +1485,99 @@ const RLS_PREDICATE_UNKNOWN_USER_VARIABLE_EXPLANATION: RuleExplanation = { 'The object then disappears not because its readers were denied, but because the narrowing they were ' + 'granted resolves to nothing.', RLS_DROPPED_CHECK, - 'An unknown key in a membership position (`field in current_user.KEY`) is never reported: there it is ' + - 'indistinguishable from a correct §7.3.1 reference an app stages.', + 'An unknown key in a membership position (`field in current_user.KEY`) is not reported by `os validate`, ' + + '`os build` or `os lint`: there it is indistinguishable from a key a self-hosted resolver the CLI cannot ' + + 'see owns. The runtime publish gate (the Studio, REST and MCP save door) knows the deployment\'s ' + + 'registered resolver and every object\'s `membershipKeys`, so there such a key is reported when no ' + + 'kernel key, declaration or resolver owns it: nothing can stage it, and every policy reading it drops.', + 'A membership key\'s `filter` may not read any `current_user` value at all. The security plugin resolves ' + + 'the key with no caller values bound, so a filter reading one never compiles, the key never resolves, ' + + 'and every policy reading the key matches no rows. Keeping the filter to the roster object\'s own ' + + 'columns is also what keeps one key from being built from another.', + ], +}; + +/** What a declared membership key is and how it resolves — the opening of every membership-key entry. */ +const MEMBERSHIP_KEY_RESOLUTION = + 'A membership key (`ObjectSchema.membershipKeys`) is declared on a roster object X: `current_user.KEY` is ' + + 'the distinct values of its `valueField` (F) on the rows of X whose `userField` (U) is the signed-in user ' + + 'and on which its optional `filter` holds, read once per request by the platform itself, inside the ' + + 'caller\'s organization wall. A policy then tests a record against the set: `supplier in ' + + 'current_user.portal_supplier_ids`. Every miss fails closed: the key stays unresolved, every policy ' + + 'reading it is dropped, and the records it was meant to grant are not readable.'; + +const MEMBERSHIP_KEY_USER_FIELD_INVALID_EXPLANATION: RuleExplanation = { + rule: 'membership-key-user-field-invalid', + covers: 'which column a membership key matches the user on', + paragraphs: [ + MEMBERSHIP_KEY_RESOLUTION, + 'The read matches U against ONE user id, the caller\'s. So U must be a column of X that holds one user: ' + + 'a `user` field, or a `lookup` or `master_detail` field to `sys_user`, not flagged `multiple: true`. A ' + + 'column X does not have makes the read fail; a text column, or a reference to another object, holds ' + + 'values that are not user ids, so the key resolves to the wrong rows or none; a multi-user column ' + + 'cannot equal one id. A column the platform injects (`owner_id`, `created_by`) is a column of X and is ' + + 'judged by its injected definition.', + 'U is judged on the object as it runs: an `objectExtensions` entry that replaces the column is folded in ' + + 'first, by the registry\'s own merge order. A column two packages write at the same deciding priority is ' + + 'not judged, because registration order picks the winner.', + ], +}; + +const MEMBERSHIP_KEY_VALUE_FIELD_INVALID_EXPLANATION: RuleExplanation = { + rule: 'membership-key-value-field-invalid', + covers: 'which column a membership key collects', + paragraphs: [ + MEMBERSHIP_KEY_RESOLUTION, + 'The set holds F\'s values, and a policy compares it with a column that references the same object, or ' + + 'with `id` on that object. So F must be a reference column of X: `lookup`, `master_detail` or `user`. A ' + + 'multi-valued one is allowed, and its values are flattened. A column X does not have makes the read ' + + 'fail; any other type holds values no policy can compare with a record. F may not be U\'s column: the ' + + 'schema refuses that, because the set would hold only the caller\'s own id.', + 'F is judged on the object as it runs, an `objectExtensions` entry that replaces it folded in first.', + ], +}; + +const MEMBERSHIP_KEY_DECLARED_TWICE_EXPLANATION: RuleExplanation = { + rule: 'membership-key-declared-twice', + covers: 'why one membership key has one roster object', + paragraphs: [ + MEMBERSHIP_KEY_RESOLUTION, + 'Key names share one namespace across the deployment. When two objects declare the same key, the ' + + 'platform cannot tell which roster defines it, so neither declaration resolves and the request that ' + + 'meets it logs an error. `defineStack` and `composeStacks` refuse the pair inside one stack; the ' + + 'runtime save door also sees the objects already deployed, so it refuses a declaration of a key a live ' + + 'object declares. Rename one of the keys, or remove one declaration.', + ], +}; + +const MEMBERSHIP_KEY_RESOLVER_COLLISION_EXPLANATION: RuleExplanation = { + rule: 'membership-key-resolver-collision', + covers: 'why a declared key may not be a resolver key', + paragraphs: [ + MEMBERSHIP_KEY_RESOLUTION, + 'A deployment can also register a code resolver (`IRlsMembershipResolver`, ADR-0105 D11) that stages ' + + 'the keys it lists in `keys`. One key has one owner: when a declaration and the resolver both claim it, ' + + 'the platform takes neither value, every policy reading the key matches no rows, and the boot logs an ' + + 'error naming both owners. Only the runtime save door knows the registered resolver, so `os validate`, ' + + '`os build` and `os lint` do not judge this. Rename the declared key, or drop it from the resolver.', + ], +}; + +const RLS_PREDICATE_MEMBERSHIP_KEY_TARGET_EXPLANATION: RuleExplanation = { + rule: 'rls-predicate-membership-key-target', + covers: 'what a membership key can be compared with', + paragraphs: [ + MEMBERSHIP_KEY_RESOLUTION, + 'The set holds ids of the object F references; call it T (`sys_user` for a `user` field). A policy can ' + + 'only find those ids in a column that references T, or in the `id` of T itself. Tested against a ' + + 'reference to another object, against a column that is not a reference, or against the `id` of another ' + + 'object, the set never matches: `x in KEY` matches no row, and `!(x in KEY)` matches every row. Neither ' + + 'is the policy that was written. The compiler accepts the comparison, because it knows the predicate ' + + 'and not what the declaration collects; the rule reads the declaration.', + 'So test the key against the column that references T — on a purchase order whose `supplier` is a lookup ' + + 'to the supplier object, `supplier in current_user.portal_supplier_ids` — or against `id` when the ' + + 'policy is on T itself. A key whose declaring object is not in the stack, or that two objects declare, ' + + 'is not judged here.', ], }; @@ -4810,6 +4905,11 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [RLS_PREDICATE_UNENFORCEABLE_EXPLANATION.rule]: RLS_PREDICATE_UNENFORCEABLE_EXPLANATION, [RLS_PREDICATE_UNKNOWN_FIELD_EXPLANATION.rule]: RLS_PREDICATE_UNKNOWN_FIELD_EXPLANATION, [RLS_PREDICATE_UNKNOWN_USER_VARIABLE_EXPLANATION.rule]: RLS_PREDICATE_UNKNOWN_USER_VARIABLE_EXPLANATION, + [RLS_PREDICATE_MEMBERSHIP_KEY_TARGET_EXPLANATION.rule]: RLS_PREDICATE_MEMBERSHIP_KEY_TARGET_EXPLANATION, + [MEMBERSHIP_KEY_USER_FIELD_INVALID_EXPLANATION.rule]: MEMBERSHIP_KEY_USER_FIELD_INVALID_EXPLANATION, + [MEMBERSHIP_KEY_VALUE_FIELD_INVALID_EXPLANATION.rule]: MEMBERSHIP_KEY_VALUE_FIELD_INVALID_EXPLANATION, + [MEMBERSHIP_KEY_DECLARED_TWICE_EXPLANATION.rule]: MEMBERSHIP_KEY_DECLARED_TWICE_EXPLANATION, + [MEMBERSHIP_KEY_RESOLVER_COLLISION_EXPLANATION.rule]: MEMBERSHIP_KEY_RESOLVER_COLLISION_EXPLANATION, [SHARING_RULE_UNLOWERABLE_CONDITION_EXPLANATION.rule]: SHARING_RULE_UNLOWERABLE_CONDITION_EXPLANATION, [SHARING_RULE_RUNTIME_VARIABLE_CONDITION_EXPLANATION.rule]: SHARING_RULE_RUNTIME_VARIABLE_CONDITION_EXPLANATION, [SHARING_RULE_OBJECT_NOT_SHAREABLE_EXPLANATION.rule]: SHARING_RULE_OBJECT_NOT_SHAREABLE_EXPLANATION, diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index bbc001de712..d905847c860 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -156,6 +156,13 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // Written in here the same way #15254 wrote `validateReferenceIntegrity` // in: a ruled join is a line in this list, a drift is a red test. 'validateSecurityRoleWord', + // [#25234] Both join, as ruled joins. An object's `membershipKeys` + // filter is an RLS clause, and an object write can drop a declared key + // (or break a `defaultRowScope`) that a stored policy reads, so the RLS + // rule judges object writes; the declarations rule refuses a key whose + // two columns cannot resolve, at the only door that writes one. + 'validateRlsPredicateEnforceability', + 'validateMembershipKeyDeclarations', 'validateRuleCompilability', 'validateRuleSchemaFormats', ]); @@ -431,6 +438,8 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () 'lintAutonumberFormats', 'validateSecurityPosture', 'validateSecurityRoleWord', // [#19370] — see the roster pin above + 'validateRlsPredicateEnforceability', // [#25234] — see the roster pin above + 'validateMembershipKeyDeclarations', // [#25234] 'validateRuleCompilability', 'validateRuleSchemaFormats', ]); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index b005353ae42..c1ca0af28a9 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -74,6 +74,7 @@ import { type AuthoringRuleContext, } from './authoring-rules.js'; import { isSystemObject } from './validate-security-posture.js'; +import { membershipKeyOwnersOf } from './validate-membership-key-declarations.js'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; type AnyRec = Record; @@ -1086,6 +1087,16 @@ export function runRuntimeAuthoringRules(args: { * the body as sent. ⛔ Positions only: no credential value reaches this gate. */ restoredCredentialPaths?: readonly string[]; + /** + * [#25234] The keys the host's registered membership resolver owns + * (`IRlsMembershipResolver.keys`), `[]` when the host has none registered. + * Present, the gate states the deployment's membership-key owners to the + * rules (`AuthoringRuleContext.membershipKeyOwners`): these keys, plus every + * key the WHOLE `context.objects` declares, read before the package-closure + * narrowing. Omitted, the rules judge as on the CLI, where any key may have + * an owner they cannot see. + */ + rlsMembershipResolverKeys?: readonly string[]; }): RuntimeGateResult { const rules = runtimeAuthoringRulesFor(args.type); const empty: RuntimeGateResult = { errors: [], advisories: [], rulesRun: [] }; @@ -1138,6 +1149,12 @@ export function runRuntimeAuthoringRules(args: { ), } : {}), + // [#25234] Read off the host's WHOLE collection, never the narrowed + // snapshot: a key declared in a package outside this write's closure is + // still owned. The same value in every pass, so it moves no baseline. + ...(args.rlsMembershipResolverKeys !== undefined + ? { membershipKeyOwners: membershipKeyOwnersOf(args.context?.objects, args.rlsMembershipResolverKeys) } + : {}), }; const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint)); // [#22118] The stored universe answers for a sibling's finding that needs diff --git a/packages/lint/src/validate-membership-key-declarations.test.ts b/packages/lint/src/validate-membership-key-declarations.test.ts new file mode 100644 index 00000000000..80937693066 --- /dev/null +++ b/packages/lint/src/validate-membership-key-declarations.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A, refusals 3 to 6] A membership key's declaration is judged + * at authoring time: its `userField` and `valueField` on the merged object, + * one key on two objects (the runtime door's leg), and a key the registered + * resolver also owns (the runtime door alone). + * + * The security plugin resolves each declaration per request and fails closed + * on every one of these shapes, so each is a key that reads as a grant and + * matches no rows. Every object fixture passes `ObjectSchema` first, so no case + * here exercises a shape an author cannot write. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectSchema } from '@objectstack/spec/data'; + +import { + MEMBERSHIP_KEY_DECLARED_TWICE, + MEMBERSHIP_KEY_RESOLVER_COLLISION, + MEMBERSHIP_KEY_USER_FIELD_INVALID, + MEMBERSHIP_KEY_VALUE_FIELD_INVALID, + membershipKeyOwnersOf, + validateMembershipKeyDeclarations, + type MembershipKeyFinding, +} from './validate-membership-key-declarations.js'; +import { runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; +import { explainRule } from './rule-explanations.js'; + +type AnyRec = Record; + +/** Parse `def` through `ObjectSchema`, or throw naming every issue. */ +function parsed(def: AnyRec): AnyRec { + const result = ObjectSchema.safeParse(def); + if (!result.success) { + throw new Error( + 'object fixture is not spec-valid: ' + + result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '), + ); + } + return result.data as unknown as AnyRec; +} + +const ROSTER_FIELDS = { + user: { type: 'user', label: 'User' }, + supplier: { type: 'lookup', reference: 'srm_supplier', label: 'Supplier' }, + suppliers: { type: 'lookup', reference: 'srm_supplier', multiple: true, label: 'Suppliers' }, + contact_user: { type: 'lookup', reference: 'sys_user', label: 'Contact user' }, + watchers: { type: 'lookup', reference: 'sys_user', multiple: true, label: 'Watchers' }, + supplier_name: { type: 'text', label: 'Supplier name' }, + active: { type: 'boolean', label: 'Active' }, +} as const; + +/** The roster object, declaring `portal_supplier_ids` with `key`'s members. */ +function roster(key: AnyRec = {}, name = 'srm_supplier_contact'): AnyRec { + return parsed({ + name, + label: 'Supplier contact', + fields: ROSTER_FIELDS, + membershipKeys: { + portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true', ...key }, + }, + }); +} + +const supplier = parsed({ name: 'srm_supplier', label: 'Supplier', fields: { title: { type: 'text', label: 'Title' } } }); + +const KEY_PATH = 'objects[0].membershipKeys.portal_supplier_ids'; + +const brief = (fs: readonly MembershipKeyFinding[]) => fs.map((f) => ({ rule: f.rule, path: f.path })); + +/** Every verdict and hint these cases fire, for the one-line check at the end. */ +const fired: MembershipKeyFinding[] = []; +function judge(stack: AnyRec, options: Parameters[1] = {}) { + const findings = validateMembershipKeyDeclarations(stack, options); + fired.push(...findings); + return findings; +} + +describe('validateMembershipKeyDeclarations — refusal 5: `userField` is a single-valued user reference', () => { + it('CONTROL: a `user` field, a lookup to `sys_user` and an injected `owner_id` are accepted', () => { + expect(judge({ objects: [roster(), supplier] })).toEqual([]); + expect(judge({ objects: [roster({ userField: 'contact_user' }), supplier] })).toEqual([]); + expect(judge({ objects: [roster({ userField: 'owner_id' }), supplier] })).toEqual([]); + }); + + it('refuses a column the object does not declare, naming the nearest one', () => { + const findings = judge({ objects: [roster({ userField: 'usr' }), supplier] }); + expect(brief(findings)).toEqual([{ rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }]); + expect(findings[0].where).toBe('membership key "portal_supplier_ids" of object "srm_supplier_contact"'); + expect(findings[0].message).toContain("names 'usr', which object 'srm_supplier_contact' does not declare"); + expect(findings[0].hint).toContain('Did you mean "user"?'); + }); + + it.each([ + ['a text column', 'supplier_name', 'is a `text` field, not a user reference'], + ['a lookup to another object', 'supplier', "is a `lookup` field to 'srm_supplier', not a user reference"], + ['a multi-user lookup', 'watchers', 'holds several users (`multiple: true`)'], + ])('refuses %s', (_label, userField, verdict) => { + // `valueField` moves off `supplier` when U takes it, so only U is judged. + const findings = judge({ + objects: [roster({ userField, valueField: userField === 'supplier' ? 'suppliers' : 'supplier' }), supplier], + }); + expect(brief(findings)).toEqual([{ rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }]); + expect(findings[0].message).toContain(verdict); + expect(findings[0].severity).toBe('error'); + }); + + it('judges U on the MERGED object: an extension that replaces it with a text column is refused', () => { + const stack = { + objects: [roster(), supplier], + objectExtensions: [{ extend: 'srm_supplier_contact', fields: { user: { type: 'text', label: 'User' } } }], + }; + expect(brief(judge(stack))).toEqual([{ rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }]); + // CONTROL: an extension that adds an unrelated column leaves U as declared. + const unrelated = { + objects: [roster(), supplier], + objectExtensions: [{ extend: 'srm_supplier_contact', fields: { region: { type: 'text', label: 'Region' } } }], + }; + expect(judge(unrelated)).toEqual([]); + }); + + it('leaves a column two packages write at the deciding priority unjudged', () => { + const textUser = { extend: 'srm_supplier_contact', fields: { user: { type: 'text', label: 'User' } } }; + const stack = { + objects: [roster(), supplier], + packages: [{ manifest: { objectExtensions: [textUser] } }, { manifest: { objectExtensions: [textUser] } }], + }; + expect(judge(stack)).toEqual([]); + // CONTROL: one package writing it is judged. + expect(brief(judge({ ...stack, packages: [stack.packages[0]] }))).toEqual([ + { rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }, + ]); + }); +}); + +describe('validateMembershipKeyDeclarations — refusal 6: `valueField` is a reference', () => { + it('CONTROL: a lookup, a multi-valued lookup and a `user` field are accepted', () => { + expect(judge({ objects: [roster({ valueField: 'suppliers' }), supplier] })).toEqual([]); + expect(judge({ objects: [roster({ valueField: 'contact_user' }), supplier] })).toEqual([]); + }); + + it('refuses a column the object does not declare, and a column that is no reference', () => { + const missing = judge({ objects: [roster({ valueField: 'supplierr' }), supplier] }); + expect(brief(missing)).toEqual([{ rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, path: `${KEY_PATH}.valueField` }]); + expect(missing[0].hint).toContain('Did you mean "supplier"?'); + + const text = judge({ objects: [roster({ valueField: 'supplier_name' }), supplier] }); + expect(brief(text)).toEqual([{ rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, path: `${KEY_PATH}.valueField` }]); + expect(text[0].message).toContain('is a `text` field, not a reference'); + }); +}); + +describe('validateMembershipKeyDeclarations — refusal 3: one key on two objects', () => { + it('reports both declarations inside one stack', () => { + const other = roster({}, 'srm_buyer_contact'); + const findings = judge({ objects: [roster(), other, supplier] }); + expect(brief(findings)).toEqual([ + { rule: MEMBERSHIP_KEY_DECLARED_TWICE, path: KEY_PATH }, + { rule: MEMBERSHIP_KEY_DECLARED_TWICE, path: 'objects[1].membershipKeys.portal_supplier_ids' }, + ]); + expect(findings[0].message).toContain("also declared by object 'srm_buyer_contact'"); + }); + + it('at the runtime door, reads the live objects the stack does not carry', () => { + const live = membershipKeyOwnersOf([roster({}, 'srm_buyer_contact')], []); + expect(brief(judge({ objects: [roster(), supplier] }, { membershipKeyOwners: live }))).toEqual([ + { rule: MEMBERSHIP_KEY_DECLARED_TWICE, path: KEY_PATH }, + ]); + // CONTROL: the written object's own stored self is not a second declaration. + const self = membershipKeyOwnersOf([roster()], []); + expect(judge({ objects: [roster(), supplier] }, { membershipKeyOwners: self })).toEqual([]); + }); +}); + +describe('validateMembershipKeyDeclarations — refusal 4: a key the registered resolver owns', () => { + it('is refused when the runtime door states the resolver owns it, and not judged on the CLI', () => { + const owners = membershipKeyOwnersOf([], ['portal_supplier_ids']); + const findings = judge({ objects: [roster(), supplier] }, { membershipKeyOwners: owners }); + expect(brief(findings)).toEqual([{ rule: MEMBERSHIP_KEY_RESOLVER_COLLISION, path: KEY_PATH }]); + expect(findings[0].message).toContain('also owned by the registered membership resolver'); + // CONTROL: the resolver owns other keys; and the CLI states no resolver. + expect(judge({ objects: [roster(), supplier] }, { membershipKeyOwners: membershipKeyOwnersOf([], ['team_ids']) })).toEqual([]); + expect(judge({ objects: [roster(), supplier] })).toEqual([]); + }); +}); + +describe('the doors', () => { + it('`runAuthoringRules` (the CLI registry door) reports a declaration fault', () => { + const stack = { objects: [roster({ userField: 'supplier_name' }), supplier] }; + const cli = runAuthoringRules('validate', { normalized: stack, parsed: stack }).filter((f) => + f.rule.startsWith('membership-key-'), + ); + expect(cli.map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }, + ]); + }); + + it('the runtime publish gate runs the rule for `object` writes and refuses with the resolver\'s keys', () => { + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateMembershipKeyDeclarations'); + expect(runtimeAuthoringRulesFor('permission').map((r) => r.name)).not.toContain('validateMembershipKeyDeclarations'); + + const item = roster(); + const saved = runRuntimeAuthoringRules({ + type: 'object', + item, + context: { objects: [supplier], permissions: [] }, + rlsMembershipResolverKeys: ['portal_supplier_ids'], + }); + expect(saved.errors.filter((f) => f.rule.startsWith('membership-key-')).map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: MEMBERSHIP_KEY_RESOLVER_COLLISION, path: 'objects.srm_supplier_contact.membershipKeys.portal_supplier_ids' }, + ]); + // CONTROL: a host that states no resolver key, and one that states nothing. + for (const keys of [[], undefined]) { + const clean = runRuntimeAuthoringRules({ + type: 'object', + item, + context: { objects: [supplier], permissions: [] }, + ...(keys !== undefined ? { rlsMembershipResolverKeys: keys } : {}), + }); + expect(clean.errors.filter((f) => f.rule.startsWith('membership-key-'))).toEqual([]); + } + }); + + it('the runtime gate refuses a declaration of a key another live object declares, outside the package closure too', () => { + const otherRoster = { ...roster({}, 'srm_buyer_contact'), _packageId: 'app.buyers' }; + const saved = runRuntimeAuthoringRules({ + type: 'object', + item: { ...roster(), _packageId: 'app.srm' }, + context: { objects: [supplier, otherRoster], permissions: [] }, + packageScope: { packageId: 'app.srm', dependencies: [] }, + rlsMembershipResolverKeys: [], + }); + expect(saved.errors.filter((f) => f.rule.startsWith('membership-key-')).map((f) => f.rule)).toEqual([ + MEMBERSHIP_KEY_DECLARED_TWICE, + ]); + }); +}); + +describe('os explain and one-line verdicts', () => { + it('each new id has an explanation that says how a key resolves', () => { + for (const id of [ + MEMBERSHIP_KEY_USER_FIELD_INVALID, + MEMBERSHIP_KEY_VALUE_FIELD_INVALID, + MEMBERSHIP_KEY_DECLARED_TWICE, + MEMBERSHIP_KEY_RESOLVER_COLLISION, + ]) { + const text = explainRule(id)?.paragraphs.join('\n') ?? ''; + expect(text, id).toContain('Every miss fails closed'); + } + }); + + it('every verdict and hint the cases above fired is one line of at most 200 characters', () => { + expect(new Set(fired.map((f) => f.rule)).size).toBe(4); + for (const f of fired) { + for (const line of [f.message, f.hint]) { + expect(line, f.rule).not.toContain('\n'); + expect(line.length, `${f.rule}: ${line}`).toBeLessThanOrEqual(200); + } + } + }); +}); diff --git a/packages/lint/src/validate-membership-key-declarations.ts b/packages/lint/src/validate-membership-key-declarations.ts new file mode 100644 index 00000000000..3a42a1c97bb --- /dev/null +++ b/packages/lint/src/validate-membership-key-declarations.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A, refusals 3 to 6] The publish refusals a membership key's + * DECLARATION earns (`ObjectSchema.membershipKeys`, ADR-0105 D11's declarative + * second source). The predicate half — a key's `filter` (refusal 7) and a + * policy reading a key (refusals 11 to 13) — is + * `validate-rls-predicate-enforceability.ts`'s, judged by the same passes as + * every row policy. + * + * ## What a declaration means at runtime + * + * The security plugin resolves `current_user.KEY` per request by reading the + * declaring roster object X: the distinct values of `valueField` (F) on the + * rows whose `userField` (U) is the caller and on which `filter` holds, inside + * the caller's Layer 0 wall (`stageDeclaredMembershipKeys`, + * `@objectstack/plugin-security`). Every miss fails closed: the key stays + * unresolved, and every policy reading it matches no rows. So each refusal + * below is a declaration that reads as a grant and resolves to nothing, or to + * a set no policy can compare. + * + * ## The refusals + * + * - {@link MEMBERSHIP_KEY_USER_FIELD_INVALID} (refusal 5) — U is not a column of + * the merged object, is not a single-valued user reference (`user`, or a + * `lookup` / `master_detail` to `sys_user`), or is `multiple: true`. The read + * matches U against one user id, so any other column matches no row or the + * wrong ones. A registry-injected column (`owner_id`, `created_by`) is a + * column of the object and is judged by its injected definition. + * - {@link MEMBERSHIP_KEY_VALUE_FIELD_INVALID} (refusal 6) — F is not a column + * of the merged object, or not a reference (`lookup`, `master_detail` or + * `user`). The set holds F's values, and a policy compares it with a column + * that references F's target or with that target's `id` (refusal 11), which + * only a reference has. `multiple: true` is allowed: its values are + * flattened. F naming U's column is refused by `MembershipKeySchema` itself. + * - {@link MEMBERSHIP_KEY_DECLARED_TWICE} (refusal 3) — one key on two objects. + * `defineStack` and `composeStacks` refuse it inside one stack; this is the + * door they do not see, the runtime publish gate, where the other declaration + * is a live registry object. At runtime neither declaration resolves. + * - {@link MEMBERSHIP_KEY_RESOLVER_COLLISION} (refusal 4) — a key the + * registered code resolver (`IRlsMembershipResolver.keys`) also owns. Only + * the runtime publish gate knows the resolver, so the CLI cannot judge it. At + * runtime neither source resolves the key, and the boot logs an error. + * + * ## Which object U and F are judged on + * + * The MERGED one, as `stack.zod.ts` judges a `controlled_by_parent` master: an + * `objectExtensions` entry that replaces U or F changes the column the read + * uses. The merge is the registry's (`mergeObjectDefinitions`): fields by + * name, a later writer replacing the whole field, extensions in ascending + * `priority` (default 200) and, at one priority, in package order. A field two + * packages write at the priority that decides it is not judged, because the + * winner is registration order, which a stack does not state. At the runtime + * gate the registry has already folded the extensions into the objects it + * hands over. + * + * ## What the runtime gate states + * + * `AuthoringRuleContext.membershipKeyOwners`: the registered resolver's keys, + * and every key the WHOLE live object collection declares. The second is the + * collection before the package-closure narrowing (#9612), because a key's + * namespace is the deployment's, not the package's. Absent (the CLI), refusal 4 + * is not judged and refusal 3 reads the stack alone. + */ + +import { + indexObjectGraph, + isUnjudgeable, + recordsOf, + resolveFieldPath, + suggestName, + type GraphField, + type ObjectGraph, +} from './object-graph.js'; + +/** `userField` is not a single-valued user reference on the merged object. */ +export const MEMBERSHIP_KEY_USER_FIELD_INVALID = 'membership-key-user-field-invalid'; +/** `valueField` is not a reference on the merged object. */ +export const MEMBERSHIP_KEY_VALUE_FIELD_INVALID = 'membership-key-value-field-invalid'; +/** One key declared on two objects. */ +export const MEMBERSHIP_KEY_DECLARED_TWICE = 'membership-key-declared-twice'; +/** A declared key the registered membership resolver also owns. */ +export const MEMBERSHIP_KEY_RESOLVER_COLLISION = 'membership-key-resolver-collision'; + +export interface MembershipKeyFinding { + severity: 'error'; + rule: string; + where: string; + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** The platform's user object, the target of every `user` field. */ +const USER_OBJECT = 'sys_user'; + +/** The field types a membership key's `valueField` may have. */ +const VALUE_FIELD_TYPES: ReadonlySet = new Set(['lookup', 'master_detail', 'user']); + +/** One `membershipKeys` entry, where the stack declares it. */ +export interface DeclaredMembershipKey { + /** The key name, `current_user.KEY`. */ + key: string; + /** The declaring roster object, X. */ + object: string; + /** X's position in `recordsOf(stack.objects)`, the finding path's index. */ + objectIndex: number; + /** U. */ + userField: string; + /** F. */ + valueField: string; + /** The optional narrowing, as written. */ + filter?: string; +} + +/** + * Every membership key the stack's objects declare, in object order then key + * order. A non-record object or declaration is skipped: its shape is the + * schema's to refuse. + */ +export function declaredMembershipKeysOf(stack: unknown): DeclaredMembershipKey[] { + const out: DeclaredMembershipKey[] = []; + if (!isRec(stack)) return out; + recordsOf(stack.objects).forEach((obj, objectIndex) => { + const object = str(obj.name); + if (!object || !isRec(obj.membershipKeys)) return; + for (const [key, decl] of Object.entries(obj.membershipKeys)) { + if (!isRec(decl)) continue; + out.push({ + key, + object, + objectIndex, + userField: str(decl.userField), + valueField: str(decl.valueField), + ...(typeof decl.filter === 'string' ? { filter: decl.filter } : {}), + }); + } + }); + return out; +} + +/** + * The deployment's membership-key owners, as the runtime publish gate states + * them (`AuthoringRuleContext.membershipKeyOwners`). + */ +export interface MembershipKeyOwners { + /** The keys the registered `rls-membership-resolver` declares; empty when none is registered. */ + readonly resolverKeys: ReadonlySet; + /** Key → the names of the live objects declaring it, read off the whole collection. */ + readonly declaredBy: ReadonlyMap; +} + +/** + * Build {@link MembershipKeyOwners} from the live object collection and the + * resolver's keys. Pure; a non-record member is skipped. + */ +export function membershipKeyOwnersOf( + objects: readonly unknown[] | undefined, + resolverKeys: readonly string[], +): MembershipKeyOwners { + const declaredBy = new Map(); + for (const decl of declaredMembershipKeysOf({ objects: objects ?? [] })) { + const names = declaredBy.get(decl.key) ?? []; + if (!names.includes(decl.object)) names.push(decl.object); + declaredBy.set(decl.key, names); + } + return { resolverKeys: new Set(resolverKeys), declaredBy }; +} + +/** + * The objects declaring `key`: the stack's own declarations, plus every live + * object the stack does not carry under its name. The stack is the authority + * for an object it carries, so the written object's declaration replaces its + * stored self's. + */ +export function objectsDeclaringKey( + key: string, + declared: readonly DeclaredMembershipKey[], + stackObjectNames: ReadonlySet, + owners: MembershipKeyOwners | undefined, +): string[] { + const names: string[] = []; + for (const decl of declared) if (decl.key === key && !names.includes(decl.object)) names.push(decl.object); + for (const name of owners?.declaredBy.get(key) ?? []) { + if (!stackObjectNames.has(name) && !names.includes(name)) names.push(name); + } + return names; +} + +/** The names of the objects a stack carries. */ +export function stackObjectNamesOf(stack: unknown): Set { + const names = new Set(); + if (!isRec(stack)) return names; + for (const obj of recordsOf(stack.objects)) if (str(obj.name)) names.add(str(obj.name)); + return names; +} + +/** One `objectExtensions` entry, as the registry folds it. */ +interface VisibleExtension { + /** Package order: registration order across packages. */ + carrier: number; + /** `priority ?? 200`, the registry's default. */ + priority: number; + fields: AnyRec[]; +} + +/** + * The stack's objects with every `objectExtensions` entry folded in, as the + * registry merges them (this file's header). A field two packages write at the + * priority that decides it is left out of the merged map and named in + * `tied`, so its verdict is "not judged" rather than either writer's. + */ +function mergedObjectsOf(stack: AnyRec): { objects: AnyRec[]; tied: Map> } { + const objects = recordsOf(stack.objects); + const tied = new Map>(); + const names = new Set(objects.map((obj) => str(obj.name)).filter(Boolean)); + const extensions = new Map(); + const collect = (entries: unknown, carrier: number) => { + if (!Array.isArray(entries)) return; + for (const entry of entries) { + if (!isRec(entry) || typeof entry.extend !== 'string' || !names.has(entry.extend)) continue; + const list = extensions.get(entry.extend) ?? []; + // One per package per target: the later entry wins. + const kept = list.filter((ext) => ext.carrier !== carrier); + kept.push({ + carrier, + priority: typeof entry.priority === 'number' ? entry.priority : 200, + fields: recordsOf(entry.fields), + }); + extensions.set(entry.extend, kept); + } + }; + if (Array.isArray(stack.packages)) { + stack.packages.forEach((pkg: unknown, j: number) => { + if (isRec(pkg) && isRec(pkg.manifest)) collect(pkg.manifest.objectExtensions, j); + }); + } else { + collect(stack.objectExtensions, 0); + } + if (extensions.size === 0) return { objects, tied }; + + const merged = objects.map((obj) => { + const name = str(obj.name); + const exts = extensions.get(name); + if (!exts) return obj; + const fields = new Map(); + for (const f of recordsOf(obj.fields)) if (str(f.name)) fields.set(str(f.name), f); + const lastWriter = new Map(); + const tiedKeys = new Set(); + for (const ext of [...exts].sort((a, b) => a.priority - b.priority || a.carrier - b.carrier)) { + for (const f of ext.fields) { + const key = str(f.name); + if (!key) continue; + const previous = lastWriter.get(key); + if (previous && previous.priority === ext.priority) tiedKeys.add(key); + else tiedKeys.delete(key); + lastWriter.set(key, ext); + fields.set(key, f); + } + } + if (tiedKeys.size > 0) tied.set(name, tiedKeys); + return { ...obj, fields: [...fields.values()] }; + }); + return { objects: merged, tied }; +} + +/** U's verdict: `null` when it is a single-valued user reference, else the reason. */ +function userFieldFault(meta: GraphField): string | null { + const isUserRef = + meta.type === 'user' || + ((meta.type === 'lookup' || meta.type === 'master_detail') && meta.reference === USER_OBJECT); + if (!isUserRef) { + const target = meta.reference ? ` to '${meta.reference}'` : ''; + return `is a \`${meta.type ?? 'untyped'}\` field${target}, not a user reference`; + } + if (meta.multiple) return 'holds several users (`multiple: true`), so no one user id can match it'; + return null; +} + +/** F's verdict: `null` when it is a reference, else the reason. */ +function valueFieldFault(meta: GraphField): string | null { + if (meta.type && VALUE_FIELD_TYPES.has(meta.type)) return null; + return `is a \`${meta.type ?? 'untyped'}\` field, not a reference (\`lookup\`, \`master_detail\` or \`user\`)`; +} + +/** + * The column one of U / F names, judged on the merged object: `ok` with its + * field slice, `missing` with the object's declared names, or `skip` when the + * graph cannot answer (an object with no field map, a field two packages tie + * on, an untyped or unread declaration). + */ +function columnOf( + graph: ObjectGraph, + object: string, + column: string, + tied: ReadonlySet | undefined, +): { kind: 'ok'; meta: GraphField } | { kind: 'missing'; candidates: ReadonlySet } | { kind: 'skip' } { + if (!column || tied?.has(column)) return { kind: 'skip' }; + const verdict = resolveFieldPath(graph, object, column); + if (!verdict || isUnjudgeable(verdict)) return { kind: 'skip' }; + if (verdict.kind === 'field-unknown') return { kind: 'missing', candidates: verdict.candidates }; + if (verdict.kind !== 'ok') return { kind: 'skip' }; + // `id` maps to an empty slice: the driver's primary key, never a reference. + return { kind: 'ok', meta: verdict.meta ?? {} }; +} + +/** + * Judge every membership key the stack declares (refusals 3 to 6). + * + * Pure `(stack, options) => Finding[]`; tolerates the normalized and the parsed + * tier. + * + * @param options.membershipKeyOwners The runtime publish gate's statement of + * the deployment's owners — see this file's header. Absent on the CLI. + */ +export function validateMembershipKeyDeclarations( + stack: unknown, + options: { membershipKeyOwners?: MembershipKeyOwners } = {}, +): MembershipKeyFinding[] { + const findings: MembershipKeyFinding[] = []; + if (!isRec(stack)) return findings; + const declared = declaredMembershipKeysOf(stack); + if (declared.length === 0) return findings; + + const { objects: merged, tied } = mergedObjectsOf(stack); + const graph = indexObjectGraph({ objects: merged }); + const stackNames = stackObjectNamesOf(stack); + const owners = options.membershipKeyOwners; + + for (const decl of declared) { + const where = `membership key "${decl.key}" of object "${decl.object}"`; + const at = `objects[${decl.objectIndex}].membershipKeys.${decl.key}`; + const tiedHere = tied.get(decl.object); + + const user = columnOf(graph, decl.object, decl.userField, tiedHere); + if (user.kind === 'missing') { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, + where, + path: `${at}.userField`, + message: + `\`userField\` names '${decl.userField}', which object '${decl.object}' does not declare, so the ` + + 'roster read fails and every policy reading the key matches no rows', + hint: + `Name the column that holds the roster row's user (\`user\`, or a lookup to \`sys_user\`).` + + suggestName(decl.userField, user.candidates), + }); + } else if (user.kind === 'ok') { + const fault = userFieldFault(user.meta); + if (fault) { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, + where, + path: `${at}.userField`, + message: + `\`userField\` '${decl.userField}' ${fault}, so the roster read cannot match the caller's user id ` + + 'and the key resolves to wrong rows or none', + hint: 'Point `userField` at a single-valued `user` field, or a `lookup` / `master_detail` to `sys_user`', + }); + } + } + + const value = columnOf(graph, decl.object, decl.valueField, tiedHere); + if (value.kind === 'missing') { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, + where, + path: `${at}.valueField`, + message: + `\`valueField\` names '${decl.valueField}', which object '${decl.object}' does not declare, so the ` + + 'roster read fails and every policy reading the key matches no rows', + hint: + 'Name the reference column whose values the policies compare.' + + suggestName(decl.valueField, value.candidates), + }); + } else if (value.kind === 'ok' && decl.valueField !== decl.userField) { + const fault = valueFieldFault(value.meta); + if (fault) { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, + where, + path: `${at}.valueField`, + message: + `\`valueField\` '${decl.valueField}' ${fault}, so the set holds values no policy can compare ` + + 'with a record', + hint: 'Point `valueField` at a `lookup`, `master_detail` or `user` column, or reference the values\' object', + }); + } + } + + const others = objectsDeclaringKey(decl.key, declared, stackNames, owners).filter((n) => n !== decl.object); + if (others.length > 0) { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_DECLARED_TWICE, + where, + path: at, + message: + `\`current_user.${decl.key}\` is also declared by object ${others.map((n) => `'${n}'`).join(', ')}, ` + + 'so neither declaration resolves and every policy reading the key matches no rows', + hint: 'Declare each membership key on one roster object: rename this key or remove the other declaration', + }); + } + + if (owners?.resolverKeys.has(decl.key)) { + findings.push({ + severity: 'error', + rule: MEMBERSHIP_KEY_RESOLVER_COLLISION, + where, + path: at, + message: + `\`current_user.${decl.key}\` is also owned by the registered membership resolver, so neither ` + + 'source resolves it and every policy reading the key matches no rows', + hint: 'Rename the declared key, or drop it from the resolver\'s `keys`: one membership key has one owner', + }); + } + } + return findings; +} diff --git a/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts index cbcc4169fe1..a55178f95c4 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts @@ -206,11 +206,12 @@ describe('the judge reaches the rule through BOTH run signatures (#20158)', () = } }); - it('the rule now runs at the runtime publish gate for `permission` writes', () => { + it('the rule now runs at the runtime publish gate for `permission` and (#25234) `object` writes', () => { const entry = AUTHORING_RULES.find((r) => r.name === 'validateRlsPredicateEnforceability'); expect(entry?.surfaces).toEqual(['cli', 'runtime-publish']); - expect(entry?.runtimeTypes).toEqual(['permission']); + expect(entry?.runtimeTypes).toEqual(['permission', 'object']); expect(runtimeAuthoringRulesFor('permission').map((r) => r.name)).toContain('validateRlsPredicateEnforceability'); + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateRlsPredicateEnforceability'); }); it('`runRuntimeAuthoringRules` hands `judgeFilter` to the rule, and attributes only what the WRITE adds', () => { diff --git a/packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts new file mode 100644 index 00000000000..c185e605564 --- /dev/null +++ b/packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A, refusals 7, 11, 12 and 13] The `rls-predicate-*` judge + * meets declared membership keys (`ObjectSchema.membershipKeys`). + * + * - **7.** A key's `filter` is judged by the same passes as a row policy's + * `using`, with the declaring object as its target, plus one check of its + * own: it reads no `current_user` value. The security plugin compiles it + * with no caller before the roster read, so a filter that does not compile + * leaves the key unresolved. + * - **11.** A policy testing a declared key against a column whose values can + * never be the key's: a reference to another object, a column that is no + * reference, the `id` of another object. + * - **12.** At the runtime publish gate, which knows the registered resolver's + * keys, a key in a membership position that no kernel key, declaration or + * resolver owns. Unchanged on the CLI: `unknowable`. + * - **13.** A declared key under `==` is the type fault a kernel list key earns, + * not "nothing pre-resolves". + * + * Every object fixture passes `ObjectSchema` first. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectSchema } from '@objectstack/spec/data'; + +import { + RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, + RLS_PREDICATE_UNENFORCEABLE, + RLS_PREDICATE_UNKNOWN_FIELD, + RLS_PREDICATE_UNKNOWN_USER_VARIABLE, + validateRlsPredicateEnforceability, + type RlsPredicateFinding, +} from './validate-rls-predicate-enforceability.js'; +import { membershipKeyOwnersOf } from './validate-membership-key-declarations.js'; +import { runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; +import { explainRule } from './rule-explanations.js'; + +type AnyRec = Record; + +function parsed(def: AnyRec): AnyRec { + const result = ObjectSchema.safeParse(def); + if (!result.success) { + throw new Error( + 'object fixture is not spec-valid: ' + + result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '), + ); + } + return result.data as unknown as AnyRec; +} + +/** The roster object, declaring `portal_supplier_ids` with `filter`. */ +function roster(filter?: string, name = 'srm_supplier_contact'): AnyRec { + return parsed({ + name, + label: 'Supplier contact', + fields: { + user: { type: 'user', label: 'User' }, + supplier: { type: 'lookup', reference: 'srm_supplier', label: 'Supplier' }, + active: { type: 'boolean', label: 'Active' }, + notes: { type: 'json', label: 'Notes' }, + tier: { type: 'text', label: 'Tier' }, + }, + membershipKeys: { + portal_supplier_ids: { userField: 'user', valueField: 'supplier', ...(filter !== undefined ? { filter } : {}) }, + }, + }); +} + +const supplier = parsed({ + name: 'srm_supplier', + label: 'Supplier', + fields: { title: { type: 'text', label: 'Title' } }, +}); + +const po = parsed({ + name: 'srm_po', + label: 'Purchase order', + fields: { + supplier: { type: 'lookup', reference: 'srm_supplier', label: 'Supplier' }, + buyer: { type: 'user', label: 'Buyer' }, + title: { type: 'text', label: 'Title' }, + }, +}); + +/** A permission set holding one policy on `object` with `using`. */ +function portalSet(using: string, object = 'srm_po'): AnyRec { + return { + name: 'supplier_portal', + label: 'Supplier portal', + rowLevelSecurity: [{ name: 'own_supplier', object, operation: 'select', using }], + }; +} + +const POLICY_PATH = 'permissions[0].rowLevelSecurity[0].using'; +const FILTER_PATH = 'objects[0].membershipKeys.portal_supplier_ids.filter'; + +const brief = (fs: readonly RlsPredicateFinding[]) => fs.map((f) => ({ rule: f.rule, path: f.path })); + +const fired: RlsPredicateFinding[] = []; +function judge(stack: AnyRec, options: Parameters[1] = {}) { + const findings = validateRlsPredicateEnforceability(stack, options); + fired.push(...findings); + return findings; +} + +describe('refusal 7 — a membership key\'s `filter`, judged with the declaring object as its target', () => { + it('CONTROL: a filter over the object\'s own columns, and a key with no filter, report nothing', () => { + expect(judge({ objects: [roster('active == true'), supplier] })).toEqual([]); + expect(judge({ objects: [roster(), supplier] })).toEqual([]); + }); + + it('refuses a filter that does not lower, naming the key and not a permission set', () => { + const findings = judge({ objects: [roster('size(tier) > 0'), supplier] }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNENFORCEABLE, path: FILTER_PATH }]); + expect(findings[0].where).toBe('membership key "portal_supplier_ids" of object "srm_supplier_contact"'); + expect(findings[0].message).toMatch(/^membership key filter is not lowerable/); + expect(findings[0].message).toContain('the key never resolves and every policy reading it matches no rows'); + expect(findings[0].message).not.toContain('permission set'); + }); + + it('refuses a filter naming a column the declaring object lacks', () => { + const findings = judge({ objects: [roster('activ == true'), supplier] }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNKNOWN_FIELD, path: FILTER_PATH }]); + expect(findings[0].message).toContain('"srm_supplier_contact"'); + }); + + it('refuses a filter reading any `current_user` value, the kernel\'s included', () => { + for (const filter of ['active == true && user == current_user.id', 'tier in current_user.portal_supplier_ids']) { + const findings = judge({ objects: [roster(filter), supplier] }); + expect(brief(findings), filter).toEqual([{ rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, path: FILTER_PATH }]); + expect(findings[0].message).toContain('resolved with no caller'); + } + }); + + it('refuses a field-to-field comparison with a list-holding column, as the roster read does', () => { + const findings = judge({ objects: [roster('tier == notes'), supplier] }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNENFORCEABLE, path: FILTER_PATH }]); + expect(findings[0].message).toContain('the roster read is refused'); + }); + + it('hands a clean filter to the engine\'s judge with the declaring object', () => { + const calls: Array<{ object: string; where: unknown }> = []; + judge({ objects: [roster('active == true'), supplier] }, { + judgeFilter: (object, where) => { + calls.push({ object, where }); + return { ok: true } as never; + }, + }); + expect(calls).toEqual([{ object: 'srm_supplier_contact', where: { active: true } }]); + }); +}); + +describe('refusal 11 — a declared key tested against a column of another target', () => { + const stackWith = (using: string, object = 'srm_po') => ({ + objects: [roster(), supplier, po], + permissions: [portalSet(using, object)], + }); + + it('CONTROL: the referencing column, and `id` on the target itself, are accepted', () => { + expect(judge(stackWith('supplier in current_user.portal_supplier_ids'))).toEqual([]); + expect(judge(stackWith('record.supplier in current_user.portal_supplier_ids'))).toEqual([]); + expect(judge(stackWith('id in current_user.portal_supplier_ids', 'srm_supplier'))).toEqual([]); + }); + + it.each([ + ['a user reference', 'buyer in current_user.portal_supplier_ids', "'buyer', which references 'sys_user'"], + ['a text column', 'title in current_user.portal_supplier_ids', "'title', a `text` field"], + ['the `id` of another object', 'id in current_user.portal_supplier_ids', "`id` of 'srm_po'"], + ['a negated test', '!(buyer in current_user.portal_supplier_ids)', "'buyer', which references 'sys_user'"], + ])('refuses %s', (_label, using, holds) => { + const findings = judge(stackWith(using)); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, path: POLICY_PATH }]); + expect(findings[0].message).toContain(holds); + expect(findings[0].message).toContain("which holds 'srm_supplier' ids, so the two never match"); + expect(findings[0].hint).toContain("a column that references 'srm_supplier'"); + }); + + it('counts further faulty sites, and judges an object\'s default row scope too', () => { + const findings = judge(stackWith('buyer in current_user.portal_supplier_ids || title in current_user.portal_supplier_ids')); + expect(findings[0].message).toContain('(and 1 more)'); + + const poWithDefault = parsed({ + ...(po as AnyRec), + defaultRowScope: { name: 'po_default', operation: 'select', using: 'buyer in current_user.portal_supplier_ids' }, + }); + expect(brief(judge({ objects: [roster(), supplier, poWithDefault] }))).toEqual([ + { rule: RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, path: 'objects[2].defaultRowScope.using' }, + ]); + }); + + it('does not judge a key two objects declare, or one whose declaring object the stack does not carry', () => { + const twice = { objects: [roster(), roster(undefined, 'srm_buyer_contact'), supplier, po], permissions: [portalSet('buyer in current_user.portal_supplier_ids')] }; + expect(judge(twice).filter((f) => f.rule === RLS_PREDICATE_MEMBERSHIP_KEY_TARGET)).toEqual([]); + const elsewhere = { objects: [supplier, po], permissions: [portalSet('buyer in current_user.portal_supplier_ids')] }; + expect(judge(elsewhere, { membershipKeyOwners: membershipKeyOwnersOf([roster()], []) })).toEqual([]); + }); +}); + +describe('refusal 13 — a declared key under `==` is the list type fault, now exact', () => { + it('reports `rls-predicate-unenforceable` naming the key as a membership set', () => { + const findings = judge({ objects: [roster(), supplier, po], permissions: [portalSet('supplier == current_user.portal_supplier_ids')] }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNENFORCEABLE, path: POLICY_PATH }]); + expect(findings[0].message).toContain('`current_user.portal_supplier_ids` is a membership set, a list on every request'); + expect(findings[0].hint).toContain('supplier in current_user.portal_supplier_ids'); + }); + + it('CONTROL: with no declaration the same predicate is the unknown-variable finding it always was', () => { + const findings = judge({ objects: [supplier, po], permissions: [portalSet('supplier == current_user.portal_supplier_ids')] }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, path: POLICY_PATH }]); + expect(findings[0].message).toContain('which nothing pre-resolves'); + }); +}); + +describe('refusal 12 — a key in a membership position that nobody owns, at the runtime gate only', () => { + const stack = { objects: [roster(), supplier, po], permissions: [portalSet('supplier in current_user.portal_suplier_ids')] }; + + it('is refused when the gate states the owners, naming the nearest owned key', () => { + const findings = judge(stack, { membershipKeyOwners: membershipKeyOwnersOf([], []) }); + expect(brief(findings)).toEqual([{ rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, path: POLICY_PATH }]); + expect(findings[0].message).toContain('as a membership set, which no kernel key, membership key declaration'); + expect(findings[0].hint).toContain('Did you mean "portal_supplier_ids"?'); + }); + + it('CONTROL: unknowable on the CLI; owned by the resolver; declared by a live object; or declared here', () => { + expect(judge(stack)).toEqual([]); + expect(judge(stack, { membershipKeyOwners: membershipKeyOwnersOf([], ['portal_suplier_ids']) })).toEqual([]); + expect(judge(stack, { membershipKeyOwners: membershipKeyOwnersOf([roster(undefined, 'x')].map((o) => ({ ...o, membershipKeys: { portal_suplier_ids: { userField: 'user', valueField: 'supplier' } } })), []) })).toEqual([]); + const declaredHere = { ...stack, permissions: [portalSet('supplier in current_user.portal_supplier_ids')] }; + expect(judge(declaredHere, { membershipKeyOwners: membershipKeyOwnersOf([], []) })).toEqual([]); + }); +}); + +describe('the doors', () => { + it('the rule runs for `object` writes as well as `permission` writes', () => { + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateRlsPredicateEnforceability'); + expect(runtimeAuthoringRulesFor('permission').map((r) => r.name)).toContain('validateRlsPredicateEnforceability'); + }); + + it('an object write whose key filter does not lower is refused at the save door', () => { + const saved = runRuntimeAuthoringRules({ + type: 'object', + item: roster('size(tier) > 0'), + context: { objects: [supplier], permissions: [] }, + }); + expect(saved.errors.filter((f) => f.rule.startsWith('rls-predicate-')).map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: RLS_PREDICATE_UNENFORCEABLE, path: 'objects.srm_supplier_contact.membershipKeys.portal_supplier_ids.filter' }, + ]); + }); + + it('a permission write reading a key nobody owns is refused when the host states the resolver\'s keys', () => { + const set = portalSet('supplier in current_user.team_supplier_ids'); + const write = (keys?: readonly string[]) => + runRuntimeAuthoringRules({ + type: 'permission', + item: set, + context: { objects: [roster(), supplier, po], permissions: [] }, + ...(keys !== undefined ? { rlsMembershipResolverKeys: keys } : {}), + }).errors.filter((f) => f.rule.startsWith('rls-predicate-')); + expect(write([]).map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, path: 'permissions.supplier_portal.rowLevelSecurity[0].using' }, + ]); + expect(write(['team_supplier_ids'])).toEqual([]); + expect(write(undefined)).toEqual([]); + }); + + it('an object write that drops a key a stored policy reads is refused, and one that keeps it is not', () => { + const stored = portalSet('supplier in current_user.portal_supplier_ids'); + const write = (item: AnyRec) => + runRuntimeAuthoringRules({ + type: 'object', + item, + context: { objects: [roster(), supplier, po], permissions: [stored] }, + rlsMembershipResolverKeys: [], + }).errors.filter((f) => f.rule.startsWith('rls-predicate-')); + const withoutKey = parsed({ ...(roster() as AnyRec), membershipKeys: undefined }); + expect(write(withoutKey).map((f) => f.rule)).toEqual([RLS_PREDICATE_UNKNOWN_USER_VARIABLE]); + expect(write(roster())).toEqual([]); + }); + + it('`runAuthoringRules` (the CLI door) reports refusals 7, 11 and 13 and not 12', () => { + const stack = { + objects: [roster('size(tier) > 0'), supplier, po], + permissions: [ + { + name: 'supplier_portal', + label: 'Supplier portal', + rowLevelSecurity: [ + { name: 'a', object: 'srm_po', operation: 'select', using: 'buyer in current_user.portal_supplier_ids' }, + { name: 'b', object: 'srm_po', operation: 'select', using: 'supplier == current_user.portal_supplier_ids' }, + { name: 'c', object: 'srm_po', operation: 'select', using: 'supplier in current_user.nobody_ids' }, + ], + }, + ], + }; + const cli = runAuthoringRules('validate', { normalized: stack, parsed: stack }).filter((f) => + f.rule.startsWith('rls-predicate-'), + ); + expect(cli.map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, path: 'permissions[0].rowLevelSecurity[0].using' }, + { rule: RLS_PREDICATE_UNENFORCEABLE, path: 'permissions[0].rowLevelSecurity[1].using' }, + { rule: RLS_PREDICATE_UNENFORCEABLE, path: FILTER_PATH }, + ]); + }); +}); + +describe('os explain and one-line verdicts', () => { + it('explains the target rule, the filter and the runtime-only membership arm', () => { + expect(explainRule(RLS_PREDICATE_MEMBERSHIP_KEY_TARGET)?.paragraphs.join('\n')).toContain('`!(x in KEY)` matches every row'); + const variable = explainRule(RLS_PREDICATE_UNKNOWN_USER_VARIABLE)?.paragraphs.join('\n') ?? ''; + expect(variable).toContain('The runtime publish gate'); + expect(variable).toContain('A membership key\'s `filter` may not read any `current_user` value'); + expect(explainRule(RLS_PREDICATE_UNENFORCEABLE)?.paragraphs.join('\n')).toContain('objects[N].membershipKeys.KEY.filter'); + }); + + it('every verdict and hint the cases above fired is one line of at most 200 characters', () => { + expect(fired.some((f) => f.rule === RLS_PREDICATE_MEMBERSHIP_KEY_TARGET)).toBe(true); + expect(fired.some((f) => f.path === FILTER_PATH)).toBe(true); + for (const f of fired) { + for (const line of [f.message, f.hint]) { + expect(line, f.rule).not.toContain('\n'); + expect(line.length, `${f.rule}: ${line}`).toBeLessThanOrEqual(200); + } + } + }); +}); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.test.ts index 012b5bbb53b..b2f26985841 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.test.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.test.ts @@ -1226,7 +1226,7 @@ describe('[#22161] one-line verdicts — the five ids', () => { [RLS_PREDICATE_UNPARSEABLE]: ['legacy SQL bridge', '`IN` to `in`', 'ZERO rows', 'PermissionDeniedError', 'rls-predicate-over-budget'], [RLS_PREDICATE_OVER_BUDGET]: ['maxAstNodes', 'no syntax or dialect error', 'ZERO rows', 'PermissionDeniedError'], [RLS_PREDICATE_UNKNOWN_FIELD]: ['RENAME', 'COMPILED predicate', 'ZERO rows', 'failed OPEN'], - [RLS_PREDICATE_UNKNOWN_USER_VARIABLE]: ['§7.3.1 membership sets', 'ARRAYS', 'EVERY position', 'never reported'], + [RLS_PREDICATE_UNKNOWN_USER_VARIABLE]: ['§7.3.1 membership sets', 'ARRAYS', 'EVERY position', 'not reported by `os validate`'], }; it('covers exactly the five ids', () => { diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index bbb07af6453..a354a5d5c4d 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -294,6 +294,7 @@ import { } from '@objectstack/spec/data'; import { ExecutionContextSchema } from '@objectstack/spec/kernel'; import { + RELATIONSHIP_FIELD_TYPES, describeFieldPathVerdict, indexObjectGraph, isUnjudgeable, @@ -304,6 +305,12 @@ import { type GraphField, type ObjectGraph, } from './object-graph.js'; +import { + declaredMembershipKeysOf, + objectsDeclaringKey, + stackObjectNamesOf, + type MembershipKeyOwners, +} from './validate-membership-key-declarations.js'; /** A predicate outside the pushdown subset — the policy enforces nothing. */ export const RLS_PREDICATE_UNENFORCEABLE = 'rls-predicate-unenforceable'; @@ -321,6 +328,12 @@ export const RLS_PREDICATE_UNKNOWN_FIELD = 'rls-predicate-unknown-field'; * variable half of the same failure (#16119). */ export const RLS_PREDICATE_UNKNOWN_USER_VARIABLE = 'rls-predicate-unknown-user-variable'; +/** + * [#25234 refusal 11] A policy testing a declared membership key against a + * column whose values can never be the key's: a column that references another + * object, a column that is no reference, or the `id` of another object. + */ +export const RLS_PREDICATE_MEMBERSHIP_KEY_TARGET = 'rls-predicate-membership-key-target'; export type RlsPredicateSeverity = 'error' | 'warning'; @@ -421,19 +434,42 @@ function boundsOverrunOf(bridged: string): CelBoundsOverrun | null { * names the clause, and the predicate is author text of any length. * ──────────────────────────────────────────────────────────────────────────── */ +/** + * A judged predicate: a row policy's `using` or `check`, or [#25234] a + * membership key's `filter`, which the security plugin compiles the same way + * and AND-s into the roster read that resolves the key. + */ +type Clause = 'using' | 'check' | 'filter'; + +/** How a finding names the judged predicate. */ +const CLAUSE_LABEL: Readonly> = { + using: 'RLS using', + check: 'RLS check', + filter: 'membership key filter', +}; + /** What a dropped policy no longer does, per clause. */ const DROPPED_OUTCOME: Readonly> = { using: 'grants no access', check: 'admits no write', }; +/** + * What a refused membership key filter costs: the security plugin compiles it + * before the roster read, and a filter that does not compile leaves the key + * unresolved, so every policy reading the key fails closed. + */ +const FILTER_DROPPED = 'the key never resolves and every policy reading it matches no rows'; + /** The clause that closes every verdict whose policy the runtime drops. */ -function droppedClause(clause: 'using' | 'check'): string { +function droppedClause(clause: Clause): string { + if (clause === 'filter') return `so ${FILTER_DROPPED}`; return `so the policy is dropped and ${DROPPED_OUTCOME[clause]}`; } /** The same outcome as a sentence, after a verdict that ends one of its own. */ -function droppedSentence(clause: 'using' | 'check'): string { +function droppedSentence(clause: Clause): string { + if (clause === 'filter') return `So ${FILTER_DROPPED}.`; return `The policy is dropped and ${DROPPED_OUTCOME[clause]}.`; } @@ -574,9 +610,64 @@ function unresolvedVariablePath(detail: string): string | null { type UserProbe = Record; -function baseUserProbe(): UserProbe { +/** + * [#25234] What the stack, and at the runtime publish gate the deployment, + * says about `current_user.*` keys beyond the kernel's: the membership keys + * objects declare (`ObjectSchema.membershipKeys`) and the keys the registered + * code resolver owns. + * + * A declared key is no longer an unknown: the security plugin stages it as a + * LIST on every request it resolves (`stageDeclaredMembershipKeys`), so the + * probe binds it as one ({@link membershipKeyProbe}). Under `==` it then earns + * the type fault a kernel list key earns (refusal 13), and under `in` its sites + * can be traced back to it (refusal 11). + */ +interface MembershipKeyContext { + /** Every key an object declares — the stack's, plus the deployment's at the runtime gate. */ + declared: ReadonlySet; + /** + * Key → the object its values reference: the target of the declaring + * object's `valueField`. Present only when one object the stack carries + * declares the key and that column is a judgeable reference. + */ + targetOf: ReadonlyMap; + /** + * The registered resolver's keys, stated by the runtime publish gate. + * Absent on the CLI, where a self-hosted resolver may own any key, so a key + * nothing here owns stays `unknowable` in a membership position. + */ + resolverKeys?: ReadonlySet; +} + +const NO_MEMBERSHIP_KEYS: MembershipKeyContext = { declared: new Set(), targetOf: new Map() }; + +/** The prefix of a declared key's probe member — see {@link membershipKeyProbe}. */ +const MEMBERSHIP_PROBE_PREFIX = `${PROBE_SCALAR}membership:`; + +/** + * A declared membership key's probe: a one-member list whose member names the + * key, so a lowered `in` site can be traced back to the key it tests. A list + * because that is what the key holds on every request. + */ +function membershipKeyProbe(key: string): readonly string[] { + return [`${MEMBERSHIP_PROBE_PREFIX}${key}`]; +} + +/** The declared key a lowered operand is the probe of, or `null`. */ +function membershipKeyOfOperand(operand: unknown): string | null { + if (!Array.isArray(operand) || operand.length !== 1 || typeof operand[0] !== 'string') return null; + return operand[0].startsWith(MEMBERSHIP_PROBE_PREFIX) ? operand[0].slice(MEMBERSHIP_PROBE_PREFIX.length) : null; +} + +/** Is this a declared key the kernel does not already own? */ +function isDeclaredKey(membership: MembershipKeyContext, key: string): boolean { + return membership.declared.has(key) && !PRERESOLVED_USER_KEYS.has(key); +} + +function baseUserProbe(membership: MembershipKeyContext = NO_MEMBERSHIP_KEYS): UserProbe { const probe: UserProbe = {}; for (const key of PRERESOLVED_USER_KEYS) probe[key] = kernelKeyProbe(key); + for (const key of membership.declared) if (isDeclaredKey(membership, key)) probe[key] = membershipKeyProbe(key); return probe; } @@ -653,20 +744,35 @@ function isPreresolvedPath(path: string): boolean { * rather than `while (true)`: a linter must terminate on input it did not * anticipate, and stopping early only costs a finding. */ -function resolveReferences(bridged: string): { +function resolveReferences(bridged: string, membership: MembershipKeyContext = NO_MEMBERSHIP_KEYS): { filter: Record | null; unresolvedScalars: string[]; + /** + * [#25234] The `current_user.KEY` references in a membership position that + * no kernel key or declaration owns — `unknowable` on the CLI, judged + * against the registered resolver's keys at the runtime gate (refusal 12). + */ + unresolvedMemberships: string[]; /** The probe as the last compile saw it — every discovered key bound. */ probe: UserProbe; /** The compiler's `detail` when that compile was refused `unsupported`. */ refusal: string | null; } { - const probe = baseUserProbe(); + const probe = baseUserProbe(membership); const unresolvedScalars: string[] = []; - const stop = (refusal: string | null = null) => ({ filter: null, unresolvedScalars, probe, refusal }); + const unresolvedMemberships: string[] = []; + const stop = (refusal: string | null = null) => ({ + filter: null, + unresolvedScalars, + unresolvedMemberships, + probe, + refusal, + }); for (let pass = 0; pass < 32; pass++) { const res = compileWithProbe(bridged, probe); - if (res.ok) return { filter: res.filter as Record, unresolvedScalars, probe, refusal: null }; + if (res.ok) { + return { filter: res.filter as Record, unresolvedScalars, unresolvedMemberships, probe, refusal: null }; + } if (res.reason !== 'unresolved-variable') { // [#19951] The predicate passed `isSupportedRlsExpression`, so a shape // refusal here was produced by a VALUE — a membership set under `==` / @@ -686,7 +792,9 @@ function resolveReferences(bridged: string): { setProbePath(probe, path, PROBE_SCALAR); } else { // A membership position — an app-staged §7.3.1 key is indistinguishable - // from a typo here, so this is `unknowable`, never a finding. + // from a typo here, so this is `unknowable`, never a finding — unless + // the runtime gate states who owns every key ({@link MembershipKeyContext}). + unresolvedMemberships.push(path); setProbePath(probe, path, PROBE_ARRAY); } } @@ -838,9 +946,16 @@ interface TypeFault { * The cure must also LAND: the swapped value is found in a lowered field * position, which is what the prescription names. */ -function attributeTypeFault(bridged: string, probe: UserProbe): TypeFault | null { +function attributeTypeFault( + bridged: string, + probe: UserProbe, + membership: MembershipKeyContext = NO_MEMBERSHIP_KEYS, +): TypeFault | null { const swaps: Array<{ variable: string; holds: RuntimeHolding; context: unknown; marker: unknown }> = []; - for (const key of PRERESOLVED_USER_KEYS) { + // [#25234] A declared key holds a list on every request it resolves, as a + // kernel list key does, so it is swapped the same way (refusal 13). + const owned = [...PRERESOLVED_USER_KEYS, ...[...membership.declared].filter((k) => isDeclaredKey(membership, k))]; + for (const key of owned) { const isList = Array.isArray(probe[key]); const marker = isList ? SWAP_SCALAR : [SWAP_SCALAR]; swaps.push({ @@ -1093,9 +1208,10 @@ export function listHoldingVerdict(comparisons: readonly ListHoldingComparison[] * driver-sql, every clause and operation (this file's header); the full account * is the `os explain` entry's. */ -const REFUSED_COMPARISON: Readonly> = { +const REFUSED_COMPARISON: Readonly> = { using: 'so the SQL drivers refuse every read it scopes (INVALID_FILTER / 400)', check: 'so the write check refuses the writes it judges (INVALID_FILTER / 400)', + filter: 'so the roster read is refused (INVALID_FILTER / 400) and the key never resolves', }; /* ──────────────────────────────────────────────────────────────────────────── @@ -1313,6 +1429,57 @@ function engineRefusalHint(code: string): string { ); } +/** [#25234] One lowered `in` site that tests a declared key against the wrong column (refusal 11). */ +interface MembershipTargetFault { + key: string; + /** The key's values' object. */ + target: string; + /** What the compared column holds, as a phrase. */ + holds: string; +} + +/** + * The `in` / not-`in` sites of a lowered predicate that test a declared key + * against a column whose values can never be the key's (refusal 11). + * + * The key holds ids of its `valueField`'s target T. So the compared column must + * reference T, or be the `id` of T itself. A reference to another object, a + * column that is no reference, and the `id` of another object hold values that + * never equal a T id: `x in KEY` then matches no row, and `!(x in KEY)` every + * row. Exact, because the declaration names T. A key whose target is not known + * here, and a column the graph cannot answer for, are not judged. + */ +function membershipTargetFaults( + graph: ObjectGraph, + object: string, + filter: Record, + membership: MembershipKeyContext, +): MembershipTargetFault[] { + const faults: MembershipTargetFault[] = []; + const sites = loweredSites( + filter, + (op, operand) => (op === '$in' || op === '$nin') && membershipKeyOfOperand(operand) !== null, + ); + for (const site of sites) { + const key = membershipKeyOfOperand(site.operand) as string; + const target = membership.targetOf.get(key); + if (!target || !object) continue; + if (site.field === 'id') { + if (object !== target) faults.push({ key, target, holds: `\`id\` of '${object}'` }); + continue; + } + const verdict = resolveFieldPath(graph, object, site.field); + if (!verdict || verdict.kind !== 'ok' || !verdict.meta?.type) continue; + const meta = verdict.meta; + if (!RELATIONSHIP_FIELD_TYPES.has(meta.type as string)) { + faults.push({ key, target, holds: `'${site.field}', a \`${meta.type}\` field` }); + } else if (meta.reference && meta.reference !== target) { + faults.push({ key, target, holds: `'${site.field}', which references '${meta.reference}'` }); + } + } + return faults; +} + /** * The reference pass: every finding a SHAPE-VALID predicate earns. * @@ -1323,7 +1490,7 @@ function engineRefusalHint(code: string): string { function referenceFindings( graph: ObjectGraph, source: string, - clause: 'using' | 'check', + clause: Clause, where: string, path: string, object: string, @@ -1333,15 +1500,21 @@ function referenceFindings( * policy outside the read scope, or no object named). */ judge?: FilterJudge, + /** [#25234] The membership keys the stack and the deployment declare. */ + membership: MembershipKeyContext = NO_MEMBERSHIP_KEYS, ): RlsPredicateFinding[] { const findings: RlsPredicateFinding[] = []; const bridged = sqlPredicateToCel(source); - const { filter, unresolvedScalars, probe, refusal } = resolveReferences(bridged); + const { filter, unresolvedScalars, unresolvedMemberships, probe, refusal } = resolveReferences(bridged, membership); for (const fieldPath of filterFieldPaths(filter)) { const verdict = resolveFieldPath(graph, object, fieldPath); if (isUnjudgeable(verdict) || !verdict) continue; - const account = describeFieldPathVerdict(verdict, fieldPath, `RLS ${clause} predicate field`); + const account = describeFieldPathVerdict( + verdict, + fieldPath, + clause === 'filter' ? 'membership key filter field' : `${CLAUSE_LABEL[clause]} predicate field`, + ); if (!account) continue; findings.push({ severity: 'error', @@ -1364,7 +1537,7 @@ function referenceFindings( where, path, message: - `RLS ${clause} reads \`${variablePath}\`, which nothing pre-resolves, in a scalar position no request ` + + `${CLAUSE_LABEL[clause]} reads \`${variablePath}\`, which nothing pre-resolves, in a scalar position no request ` + `can ever fill, ${droppedClause(clause)}`, // [#22161] One remedy line, naming only the fixed key set and the // nearest key, so no author text can lengthen it. What each key holds, @@ -1375,17 +1548,42 @@ function referenceFindings( }); } + // [#25234 refusal 12] A key in a membership position that no kernel key, + // declaration or registered resolver owns. Judged only where the runtime + // gate states the resolver's keys: on the CLI a self-hosted resolver may own + // any key, so there it stays `unknowable`. A nested path is not a key. + const resolverKeys = membership.resolverKeys; + if (resolverKeys) { + for (const variablePath of unresolvedMemberships) { + const key = variablePath.slice('current_user.'.length); + if (key.includes('.') || resolverKeys.has(key) || membership.declared.has(key)) continue; + const known = [...membership.declared, ...resolverKeys]; + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, + where, + path, + message: + `${CLAUSE_LABEL[clause]} reads \`${variablePath}\` as a membership set, which no kernel key, membership ` + + `key declaration or registered resolver owns, ${droppedClause(clause)}`, + hint: + `Declare \`${key}\` in its roster object's \`membershipKeys\`, or test a key that is declared.` + + suggestName(key, known), + }); + } + } + // [#19951] The refusals the shape check cannot see (the block above // {@link attributeTypeFault}). A refusal no single type swap cures — the // probe-artefact class — reports nothing. - const fault = refusal !== null ? attributeTypeFault(bridged, probe) : null; + const fault = refusal !== null ? attributeTypeFault(bridged, probe, membership) : null; if (refusal !== null && fault) { findings.push({ severity: 'error', rule: RLS_PREDICATE_UNENFORCEABLE, where, path, - message: `RLS ${clause}: ${HOLDING_VERDICT[fault.holds](fault.variable)}, ${droppedClause(clause)}`, + message: `${CLAUSE_LABEL[clause]}: ${HOLDING_VERDICT[fault.holds](fault.variable)}, ${droppedClause(clause)}`, hint: firstAndCount(fault.sites.map((site) => typeFaultRewrite(fault, site))), }); } @@ -1401,7 +1599,7 @@ function referenceFindings( // [#20212] `RLSCompiler.compileFilter` runs the same two faces on every // compiled policy filter, `using` and `check` alike, so the policy is // dropped on every request, exactly as a TYPE fault is. - message: `RLS ${clause} lowers to a refused comparand (${firstSentence(faced)}), ${droppedClause(clause)}`, + message: `${CLAUSE_LABEL[clause]} lowers to a refused comparand (${firstSentence(faced)}), ${droppedClause(clause)}`, hint: rewrites.length > 0 ? firstAndCount(rewrites) : COMPARAND_REWRITE, }); } @@ -1416,7 +1614,7 @@ function referenceFindings( rule: RLS_PREDICATE_UNENFORCEABLE, where, path, - message: `RLS ${clause} ${listHoldingVerdict(listComparisons)}, ${REFUSED_COMPARISON[clause]}`, + message: `${CLAUSE_LABEL[clause]} ${listHoldingVerdict(listComparisons)}, ${REFUSED_COMPARISON[clause]}`, hint: 'Compare with a single-valued column, a literal or a `current_user` value ' + '(`record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook', @@ -1434,13 +1632,30 @@ function referenceFindings( rule: RLS_PREDICATE_UNENFORCEABLE, where, path, - message: `RLS ${clause} ${crossClassVerdict(crossClass)}, ${REFUSED_COMPARISON[clause]}`, + message: `${CLAUSE_LABEL[clause]} ${crossClassVerdict(crossClass)}, ${REFUSED_COMPARISON[clause]}`, hint: 'Compare a field only with a field of its comparison class, or with a literal or a `current_user` value; ' + 'if both columns do hold comparable values, fix the declaration', }); } + // [#25234 refusal 11] A declared key tested against a column whose values + // can never be the key's. Every clause; ahead of the engine's pass. + const targetFaults = filter ? membershipTargetFaults(graph, object, filter, membership) : []; + if (targetFaults.length > 0) { + const first = targetFaults[0]; + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_MEMBERSHIP_KEY_TARGET, + where, + path, + message: + `${CLAUSE_LABEL[clause]} tests ${first.holds} against \`current_user.${first.key}\`, which holds ` + + `'${first.target}' ids, so the two never match${targetFaults.length > 1 ? ` (and ${targetFaults.length - 1} more)` : ''}`, + hint: `Test the key against a column that references '${first.target}', or against \`id\` on '${first.target}'`, + }); + } + // [#20158] The engine's own admission of the lowered read scope — run only // on a clause every pass above left clean, so one defect earns one finding. // The verdict and its sentence are the engine's; this rule adds the clause. @@ -1456,7 +1671,7 @@ function referenceFindings( where, path, message: - `RLS ${clause} (${refused.code} / ${refused.status}): ${refusalVerdict(firstSentence(refused.message))}`, + `${CLAUSE_LABEL[clause]} (${refused.code} / ${refused.status}): ${refusalVerdict(firstSentence(refused.message))}`, hint: engineRefusalHint(refused.code), }); } @@ -1474,6 +1689,13 @@ interface PolicySite { path: string; } +/** The rule's inputs beyond the stack. */ +interface JudgeOptions { + judgeFilter?: IObjectQLEngine['judgeFilter']; + /** [#25234] The deployment's membership-key owners, stated by the runtime publish gate. */ + membershipKeyOwners?: MembershipKeyOwners; +} + /** * Every finding one policy's `using` and `check` earn — the whole judgement, * shape pass and reference pass, for a policy wherever it is declared. The @@ -1484,7 +1706,8 @@ function judgePolicy( graph: ObjectGraph, policy: AnyRec, site: PolicySite, - options: { judgeFilter?: IObjectQLEngine['judgeFilter'] }, + options: JudgeOptions, + membership: MembershipKeyContext, ): RlsPredicateFinding[] { const findings: RlsPredicateFinding[] = []; for (const clause of ['using', 'check'] as const) { @@ -1493,91 +1716,154 @@ function judgePolicy( // inventing a second complaint about the shape here would be the // double-report this rule avoids for everything else. if (!source.trim()) continue; + // [#20158] The engine judges the READ SCOPE only: a `using` on a + // `select` / `all` policy that names its object. + const judge = + clause === 'using' && site.object && READ_SCOPE_OPERATIONS.has(str(policy.operation)) + ? options.judgeFilter + : undefined; + findings.push(...judgeClause(graph, source, clause, site, judge, membership)); + } + return findings; +} - // ── The verdict. This is the consumer's own function, not a model of - // it: `RLSCompiler.compileFilter` calls the SAME `isSupportedRlsExpression` - // to decide whether a dropped policy warrants its WARN. There is no - // heuristic here to drift. - const object = site.object; - const where = site.where; - const path = `${site.path}.${clause}`; - - if (isSupportedRlsExpression(source)) { - // [#16119] The shape is fine, so the REFERENCE pass owns this predicate. - // Disjoint from everything below by construction: the three shape ids - // only ever run on predicates this branch has already returned from. - // [#20158] The engine judges the READ SCOPE only: a `using` on a - // `select` / `all` policy that names its object. - const judge = - clause === 'using' && object && READ_SCOPE_OPERATIONS.has(str(policy.operation)) - ? options.judgeFilter - : undefined; - findings.push( - ...referenceFindings(graph, source, clause, where, path, object, judge), - ); - continue; - } - - // ── The explanation. Re-derived only to tell the author WHICH fix they - // need; the red/green boundary above never consults it. (Both agree by - // construction — pinned in both directions in this rule's tests.) - const bridged = sqlPredicateToCel(source); - const why = isPushdownableCel(bridged); - const detail = why.ok ? '' : why.detail; - const parseError = !why.ok && why.reason === 'parse-error'; - // A bounds overrun and a syntax fault both arrive as `parse-error` (the - // runtime deliberately collapses them — see this file's docblock), so - // the two are separated here, and only here. - const overrun = parseError ? boundsOverrunOf(bridged) : null; - - if (overrun) { - // `limit` is null only for a bounds fault this package cannot NAME - // (unreachable on cel-js 8.0.0). Degrade honestly rather than guess a - // key, which would send the author to shorten the wrong axis. - const bound = overrun.limit ?? 'an unnamed platform CEL bound'; - const budget = overrun.limitValue !== null ? ` (platform limit ${overrun.limitValue})` : ''; - const measured = overrun.measured !== null ? `, this predicate measures ${overrun.measured}` : ''; - findings.push({ - severity: 'error', - rule: RLS_PREDICATE_OVER_BUDGET, - where, - path, - message: - `RLS ${clause} is valid CEL but overruns the parse bound ${bound}${budget}${measured} ` + - `(${overrun.summary}), ${droppedClause(clause)}`, - // [#22161] One line; the four options in full, and why a top-level - // `&&` must never be split, are the explanation's. - hint: OVER_BUDGET_FIX, - }); - continue; - } +/** + * Every finding one predicate earns: the shape pass, then the reference pass. + * One function for a policy's two clauses and a membership key's `filter` + * (#25234), which the security plugin compiles by the same compiler. + */ +function judgeClause( + graph: ObjectGraph, + source: string, + clause: Clause, + site: PolicySite, + judge: FilterJudge | undefined, + membership: MembershipKeyContext, +): RlsPredicateFinding[] { + const findings: RlsPredicateFinding[] = []; + // ── The verdict. This is the consumer's own function, not a model of + // it: `RLSCompiler.compileFilter` calls the SAME `isSupportedRlsExpression` + // to decide whether a dropped policy warrants its WARN. There is no + // heuristic here to drift. + const object = site.object; + const where = site.where; + const path = `${site.path}.${clause}`; + + if (isSupportedRlsExpression(source)) { + // [#16119] The shape is fine, so the REFERENCE pass owns this predicate. + // Disjoint from everything below by construction: the three shape ids + // only ever run on predicates this branch has already returned from. + findings.push( + ...referenceFindings(graph, source, clause, where, path, object, judge, membership), + ); + return findings; + } - if (parseError) { - findings.push({ - severity: 'error', - rule: RLS_PREDICATE_UNPARSEABLE, - where, - path, - message: - `RLS ${clause} does not parse as CEL, even after the legacy SQL bridge (${detail}), ` + - droppedClause(clause), - hint: UNPARSEABLE_FIX, - }); - continue; - } + // ── The explanation. Re-derived only to tell the author WHICH fix they + // need; the red/green boundary above never consults it. (Both agree by + // construction — pinned in both directions in this rule's tests.) + const bridged = sqlPredicateToCel(source); + const why = isPushdownableCel(bridged); + const detail = why.ok ? '' : why.detail; + const parseError = !why.ok && why.reason === 'parse-error'; + // A bounds overrun and a syntax fault both arrive as `parse-error` (the + // runtime deliberately collapses them — see this file's docblock), so + // the two are separated here, and only here. + const overrun = parseError ? boundsOverrunOf(bridged) : null; + + if (overrun) { + // `limit` is null only for a bounds fault this package cannot NAME + // (unreachable on cel-js 8.0.0). Degrade honestly rather than guess a + // key, which would send the author to shorten the wrong axis. + const bound = overrun.limit ?? 'an unnamed platform CEL bound'; + const budget = overrun.limitValue !== null ? ` (platform limit ${overrun.limitValue})` : ''; + const measured = overrun.measured !== null ? `, this predicate measures ${overrun.measured}` : ''; + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_OVER_BUDGET, + where, + path, + message: + `${CLAUSE_LABEL[clause]} is valid CEL but overruns the parse bound ${bound}${budget}${measured} ` + + `(${overrun.summary}), ${droppedClause(clause)}`, + // [#22161] One line; the four options in full, and why a top-level + // `&&` must never be split, are the explanation's. + hint: OVER_BUDGET_FIX, + }); + return findings; + } + if (parseError) { findings.push({ severity: 'error', - rule: RLS_PREDICATE_UNENFORCEABLE, + rule: RLS_PREDICATE_UNPARSEABLE, where, path, - message: `RLS ${clause} is not lowerable (${refusalVerdict(detail)}), ${droppedClause(clause)}`, - hint: LOWERABLE_REWRITE, + message: + `${CLAUSE_LABEL[clause]} does not parse as CEL, even after the legacy SQL bridge (${detail}), ` + + droppedClause(clause), + hint: UNPARSEABLE_FIX, }); + return findings; } + + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + where, + path, + message: `${CLAUSE_LABEL[clause]} is not lowerable (${refusalVerdict(detail)}), ${droppedClause(clause)}`, + hint: LOWERABLE_REWRITE, + }); return findings; } +/** + * [#25234] The membership keys a predicate is judged against: every key the + * stack's objects declare, plus — at the runtime gate — every key the live + * objects declare and the registered resolver's keys. A key's target is read + * off the one declaring object the stack carries; a key two objects declare, + * or whose declaring object the stack does not carry, has none here. + */ +function membershipKeyContextOf( + cfg: AnyRec, + graph: ObjectGraph, + owners: MembershipKeyOwners | undefined, +): MembershipKeyContext { + const declaredHere = declaredMembershipKeysOf(cfg); + if (declaredHere.length === 0 && !owners) return NO_MEMBERSHIP_KEYS; + const declared = new Set(declaredHere.map((d) => d.key)); + const stackNames = stackObjectNamesOf(cfg); + // A stored declaration counts only while the stack does not carry (and so + // override) every object that declares it. + for (const key of owners?.declaredBy.keys() ?? []) { + if (objectsDeclaringKey(key, declaredHere, stackNames, owners).length > 0) declared.add(key); + } + const targetOf = new Map(); + for (const decl of declaredHere) { + if (objectsDeclaringKey(decl.key, declaredHere, stackNames, owners).length !== 1) continue; + const verdict = resolveFieldPath(graph, decl.object, decl.valueField); + if (!verdict || verdict.kind !== 'ok') continue; + const meta: GraphField = verdict.meta ?? {}; + if (meta.type && RELATIONSHIP_FIELD_TYPES.has(meta.type) && meta.reference) targetOf.set(decl.key, meta.reference); + } + return { declared, targetOf, ...(owners ? { resolverKeys: owners.resolverKeys } : {}) }; +} + +/** + * The first `current_user` value a membership key's `filter` reads, or `null`. + * + * Asked of the compiler, with the caller context the security plugin hands + * it for a filter: an empty one. Any `current_user.*` is then unresolved, as it + * is at runtime, whatever the key. + */ +function callerValueRead(source: string): string | null { + const res = compileCelToFilter(sqlPredicateToCel(source), { variables: { current_user: {} } }); + if (res.ok || res.reason !== 'unresolved-variable') return null; + const path = unresolvedVariablePath(res.detail); + return path && (path === 'current_user' || path.startsWith('current_user.')) ? path : null; +} + /** * Gate every stack-declared RLS predicate on the ONE thing the runtime does * with it: lower it to a FilterCondition (ADR-0056 D4). @@ -1589,10 +1875,15 @@ function judgePolicy( * (`IObjectQLEngine.judgeFilter`, bound to its engine). Present, every * read-scope `using` that the passes above leave clean is judged by it — see * this file's header. Absent, that pass is skipped and nothing else changes. + * @param options.membershipKeyOwners [#25234] The deployment's membership-key + * owners — the registered resolver's keys and every key the live objects + * declare — stated by the runtime publish gate. Present, a key in a + * membership position that nobody owns is refused (refusal 12). Absent (the + * CLI), such a key stays `unknowable`, as before. */ export function validateRlsPredicateEnforceability( stack: unknown, - options: { judgeFilter?: IObjectQLEngine['judgeFilter'] } = {}, + options: JudgeOptions = {}, ): RlsPredicateFinding[] { const findings: RlsPredicateFinding[] = []; const cfg = (stack ?? {}) as AnyRec; @@ -1605,6 +1896,9 @@ export function validateRlsPredicateEnforceability( // readable field map, registry-injected system columns) are the graph's and not // re-derived here. const graph: ObjectGraph = indexObjectGraph(cfg); + // [#25234] The declared membership keys every predicate below is judged + // against: bound as lists, traced to their targets — see `## Membership keys`. + const membership = membershipKeyContextOf(cfg, graph, options.membershipKeyOwners); // The permission sets' policies (`permissions[].rowLevelSecurity[]`). recordsOf(cfg.permissions).forEach((ps, psIndex) => { @@ -1618,7 +1912,7 @@ export function validateRlsPredicateEnforceability( `permission set "${psName}" policy "${str(policy.name) || String(pIndex)}"` + (object ? ` on object "${object}"` : ''), path: `permissions[${psIndex}].rowLevelSecurity[${pIndex}]`, - }, options), + }, options, membership), ); }); }); @@ -1639,9 +1933,40 @@ export function validateRlsPredicateEnforceability( object, where: `default row scope${scopeName ? ` "${scopeName}"` : ''} of object "${object || String(oIndex)}"`, path: `objects[${oIndex}].defaultRowScope`, - }, options), + }, options, membership), ); }); + // [#25234 refusal 7] Each declared membership key's `filter`. The security + // plugin compiles it by the same compiler, against the declaring object's + // columns and with NO caller, before the roster read; a filter that does not + // compile leaves the key unresolved. So it is judged by the same passes, with + // the declaring object as its target, after one check of its own: it reads no + // `current_user` value — see `## Membership keys`. + for (const decl of declaredMembershipKeysOf(cfg)) { + const source = decl.filter ?? ''; + if (!source.trim()) continue; + const site: PolicySite = { + object: decl.object, + where: `membership key "${decl.key}" of object "${decl.object}"`, + path: `objects[${decl.objectIndex}].membershipKeys.${decl.key}`, + }; + const callerRead = callerValueRead(source); + if (callerRead) { + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNKNOWN_USER_VARIABLE, + where: site.where, + path: `${site.path}.filter`, + message: + `${CLAUSE_LABEL.filter} reads \`${callerRead}\`, but a key's filter is resolved with no caller, ` + + `so ${FILTER_DROPPED}`, + hint: "Narrow the roster with this object's own columns only (`active == true`): a key is never built from a caller value", + }); + continue; + } + findings.push(...judgeClause(graph, source, 'filter', site, options.judgeFilter, membership)); + } + return findings; } diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index ccd473a8847..dd257b31bdf 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -49,6 +49,7 @@ import { EXPRESSION_INVALID, runAuthoringRules } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from './protocol.js'; import type { MetadataAuthoringChannel } from './protocol.js'; import { SDUI_MANIFEST_SERVICE } from './index.js'; +import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts'; import { PUBLIC_FORM_INTAKE_UNAVAILABLE, stampHtmlPageRequires } from './runtime-authoring-gate.js'; /** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */ @@ -4384,3 +4385,91 @@ describe('runtime authoring gate on VIEW writes — a bulk confirmText template }); } }); + +// ───────────────────────────────────────────────────────────────────────────── +// #25234 refusal 12 — the HOST fact the door judges membership-key ownership +// with. `assertRuntimeAuthoringRules` reads the registered membership +// resolver's `keys` per publish (`RLS_MEMBERSHIP_RESOLVER_SERVICE`), `[]` when +// none is registered; with that list the gate refuses a policy reading +// `current_user.KEY` as a set that no kernel key, declaration or resolver owns. +// Without a readable list (no registry, or a resolver whose `keys` is not a +// string list) the door judges no ownership, as the CLI does. +// ───────────────────────────────────────────────────────────────────────────── + +describe('runtime authoring gate on PERMISSION writes — membership-key ownership (#25234)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + const policySet = () => ({ + name: 'portal_team', + label: 'Portal Team', + objects: { leave_request: { allowRead: true, readScope: 'own' } }, + rowLevelSecurity: [ + { name: 'portal_rows', object: 'leave_request', operation: 'select', using: 'owner in current_user.portal_ids' }, + ], + }); + + const publish = (services: (() => Map) | undefined) => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine, services as any, 'env_test') as any; + const saved = protocol + .saveMetaItem({ type: 'permission', name: 'portal_team', item: policySet() }) + .then((r: unknown) => ({ result: r, err: null }), (e: unknown) => ({ result: null, err: e })); + return { rows, saved }; + }; + const permissionRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'permission'); + + it('no resolver registered: a key nothing owns is refused, and nothing is written', async () => { + const { rows, saved } = publish(() => new Map()); + const { err } = await saved; + expect({ code: (err as any)?.code, status: (err as any)?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = (err as { issues: Array<{ rule: string; path: string; message: string }> }).issues; + expect(issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([ + { rule: 'rls-predicate-unknown-user-variable', path: 'permissions.portal_team.rowLevelSecurity[0].using' }, + ]); + expect(issues[0]!.message).toContain('current_user.portal_ids'); + expect(permissionRows(rows)).toHaveLength(0); + }); + + it('a registered resolver that owns the key: the same policy publishes', async () => { + const { rows, saved } = publish( + () => new Map([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { keys: ['portal_ids'], resolve: async () => ({}) }]]), + ); + const { result, err } = await saved; + expect(err).toBeNull(); + expect((result as { success: boolean }).success).toBe(true); + expect(permissionRows(rows)).toHaveLength(1); + }); + + it('a resolver owning OTHER keys does not own this one', async () => { + const { saved } = publish( + () => new Map([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { keys: ['team_ids'], resolve: async () => ({}) }]]), + ); + const { err } = await saved; + expect((err as { issues: Array<{ rule: string }> }).issues.map((i) => i.rule)).toEqual([ + 'rls-predicate-unknown-user-variable', + ]); + }); + + it('no readable ownership fact (no registry, or unreadable `keys`): no ownership is judged', async () => { + for (const services of [ + undefined, + () => new Map([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { resolve: async () => ({}) }]]), + () => new Map([[RLS_MEMBERSHIP_RESOLVER_SERVICE, { keys: [42] }]]), + ]) { + const { rows, saved } = publish(services); + const { result, err } = await saved; + expect(err).toBeNull(); + expect((result as { success: boolean }).success).toBe(true); + expect(permissionRows(rows)).toHaveLength(1); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3b0fe3df218..6ce177e83c9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -182,6 +182,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. import type { IObjectQLEngine, IPubSub, ISecurityService } from '@objectstack/spec/contracts'; +import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts'; import { ALL_CONVERSIONS, applyConversionsToFlow, applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel, defaultRecordPageObject } from '@objectstack/spec/ui'; // [commit ece4dad31] Emitted-specifier pin. This module's inferred public declarations @@ -7468,6 +7469,11 @@ export class ObjectStackProtocolImplementation implements // read here per publish and passed in so the gate stays pure. const tenancyPostureInForce = this.tenancyPostureInForce(); + // [#25234] The keys the registered membership resolver owns — a host + // fact of the same kind, read here per publish and passed in so the + // gate stays pure. + const rlsMembershipResolverKeys = this.rlsMembershipResolverKeys(); + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -7491,6 +7497,9 @@ export class ObjectStackProtocolImplementation implements // [#20312] The deployment's component manifest, read per publish; // with it the gate compiles an html page's source (ADR-0080 §5). ...(sduiManifest !== undefined ? { sduiManifest } : {}), + // [#25234] The resolver's keys, read per publish; with them the + // gate judges who owns each membership key. + ...(rlsMembershipResolverKeys !== undefined ? { rlsMembershipResolverKeys } : {}), }); if (verdict.error) throw verdict.error; return verdict.advisories; @@ -7852,6 +7861,35 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#25234] The `current_user.*` keys the registered membership resolver + * owns (`IRlsMembershipResolver.keys`, ADR-0105 D11), `[]` when none is + * registered — the input the runtime publish gate judges membership-key + * ownership with. + * + * Read per publish, as {@link resolveSduiManifest} reads its service: a + * resolver registered after this protocol is assembled is seen by the next + * publish. `undefined` — the CLI's reading, under which no ownership is + * judged — when this host has no service registry, or when the registered + * value carries no readable `keys` list: a resolver whose keys cannot be + * read owns keys the gate cannot see, so it states none rather than the + * wrong ones. + */ + private rlsMembershipResolverKeys(): readonly string[] | undefined { + const registry = this.getServicesRegistry?.(); + if (!registry) return undefined; + let resolver: unknown; + try { + resolver = registry.get(RLS_MEMBERSHIP_RESOLVER_SERVICE); + } catch { + return undefined; + } + if (resolver === undefined || resolver === null) return []; + const keys = (resolver as { keys?: unknown }).keys; + if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) return undefined; + return keys as readonly string[]; + } + /** * Run the registered projector for a just-persisted mutation (ADR-0094). * Returns `undefined` when no projector is registered for the type; diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index cb9d5d057ff..27fd3dbac7b 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -1024,6 +1024,17 @@ export function evaluateRuntimeAuthoringGate(args: { * position is judged on the body as sent. */ restoredCredentialPaths?: readonly string[]; + /** + * [#25234] The keys the registered membership resolver owns + * (`IRlsMembershipResolver.keys` of the service registered under + * `RLS_MEMBERSHIP_RESOLVER_SERVICE`), `[]` when none is registered. A fact + * only the host holds, of the #6285 kind: gathered by the impure caller + * and handed to the shared rules, which then refuse a membership key both + * a declaration and the resolver own, and a policy reading a key in a + * membership position that nobody owns. Absent (a host without a service + * registry), both are judged as on the CLI: not at all. + */ + rlsMembershipResolverKeys?: readonly string[]; }): RuntimeAuthoringVerdict { // D1 — drafts are never gated. Publishing one runs this same function. // No rules ran, so there is nothing to report on either half. @@ -1054,6 +1065,9 @@ export function evaluateRuntimeAuthoringGate(args: { ...(args.restoredCredentialPaths !== undefined ? { restoredCredentialPaths: args.restoredCredentialPaths } : {}), + ...(args.rlsMembershipResolverKeys !== undefined + ? { rlsMembershipResolverKeys: args.rlsMembershipResolverKeys } + : {}), }); // [#6285] The gate-local refusal, folded into the SAME verdict set as the