Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/25234-membership-key-authoring-refusals.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) Refusals at the CLI and at the object and permission save doors of membership key declarations that cannot resolve and of row policies that read a membership key in a position that matches no rows, plus the RLS rule's crossing to object writes. No authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted; the repair is the author's choice of declaration, resolver, column or operator, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these verdicts (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
12 changes: 9 additions & 3 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✓ | ✓ | ✓ | ✓ᵒ |
Expand Down Expand Up @@ -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.**

Expand Down Expand Up @@ -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)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
36 changes: 30 additions & 6 deletions packages/cli/test/rls-policy-authoring-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> = () => new Map()) {
const engine = new ObjectQL({ logger: createLogger({ level: 'silent' }) });
liveEngines.push(engine);
engine.registry.logLevel = 'silent';
Expand All @@ -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<string, any>, 'env_test') };
}

interface SaveOutcome {
Expand All @@ -129,8 +130,12 @@ interface SaveOutcome {
issues: Array<{ rule: string; path: string; message: string }>;
}

async function runtimeDoor(using: string, set = permissionSet(using)): Promise<SaveOutcome> {
const { protocol } = await runtimeHost();
async function runtimeDoor(
using: string,
set = permissionSet(using),
services?: () => Map<string, unknown>,
): Promise<SaveOutcome> {
const { protocol } = await runtimeHost(services);
try {
await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: set });
return { accepted: true, issues: [] };
Expand Down Expand Up @@ -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<string, unknown>([[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');
});
});

/**
Expand Down
51 changes: 49 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading