From ed2d826c1364ac50428624f29844799cbcdc7ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 06:25:35 +0000 Subject: [PATCH 1/3] fix(lint): judge an object's defaultRowScope in the RLS predicate and org-axis rules Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24452-default-row-scope-lint.md | 11 + packages/lint/src/rule-explanations.ts | 24 +- .../src/validate-org-axis-red-lines.test.ts | 116 +++++++- .../lint/src/validate-org-axis-red-lines.ts | 45 ++- ...e-enforceability.default-row-scope.test.ts | 244 ++++++++++++++++ .../validate-rls-predicate-enforceability.ts | 260 +++++++++++------- 6 files changed, 576 insertions(+), 124 deletions(-) create mode 100644 .changeset/24452-default-row-scope-lint.md create mode 100644 packages/lint/src/validate-rls-predicate-enforceability.default-row-scope.test.ts diff --git a/.changeset/24452-default-row-scope-lint.md b/.changeset/24452-default-row-scope-lint.md new file mode 100644 index 00000000000..4ccf4f6930d --- /dev/null +++ b/.changeset/24452-default-row-scope-lint.md @@ -0,0 +1,11 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope` + +Clause-②: no + +- **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`. +- **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new. +- **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 3c3be2b1a57..b5d4e89e481 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1207,10 +1207,13 @@ const RLS_UNCOMPILABLE_DROP = 'CLOSED, which is why such a policy is survivable; it reads as an authorization and behaves as a refusal.'; /** - * What a dropped `using` does. The INSERT half: the ADR-0058 D4 write check - * takes its set from `writeCheckPolicies`, so when no applicable policy for the - * insert declares a `check`, every applicable policy's `using` is compiled as - * its check. Measured through the real `SecurityPlugin` on an `insert` and an + * What a dropped `using` does, for both sources a policy has: a permission + * set's `rowLevelSecurity[]` and an object's `defaultRowScope` (#24452), which + * the security plugin's row-policy collection applies alone, only when the + * held sets carry no row policy for the object and operation. The INSERT + * half: the ADR-0058 D4 write check takes its set from `writeCheckPolicies`, + * so when no applicable policy for the insert declares a `check`, every + * applicable policy's `using` is compiled as its check. Measured through the real `SecurityPlugin` on an `insert` and an * `all` policy, for all three kinds of drop (an unlowerable shape, an * unresolved `current_user.*`, an undeclared column): with nothing else * compiling in that set, every single-record insert is refused (403); with @@ -1221,8 +1224,13 @@ const RLS_DROPPED_USING = 'A dropped `using`: when it is the only applicable policy for that object and operation, `compileFilter` ' + 'returns the `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause, so every select / ' + 'update / delete on the object matches ZERO rows and the object disappears for every holder of the ' + - 'permission set. When other policies also apply, this one just vanishes from the OR and grants none of the ' + - 'access it appears to. On an `insert` or `all` policy the same `using` is also the single-record INSERT check ' + + 'permission set that carries the policy. An object\'s `defaultRowScope` is applied only when the caller\'s ' + + 'permission sets carry no row policy for that object and operation, so it is always the only applicable ' + + 'policy: a dropped default makes the object disappear for every caller it applies to, and a caller whose ' + + 'permission set carries its own row policy for the object is not affected. When other policies also apply, ' + + 'which only a permission set\'s policy can meet, this one just vanishes from the OR and grants none of the ' + + 'access it appears to. ' + + 'On an `insert` or `all` policy the same `using` is also the single-record INSERT check ' + 'whenever no applicable policy for the insert declares a `check` (ADR-0058 D4): when nothing else in that set ' + 'compiles, every single-record insert it governs fails with `PermissionDeniedError`; when another policy\'s ' + '`using` compiles, that one alone decides the insert.'; @@ -1324,7 +1332,9 @@ const RLS_PREDICATE_UNENFORCEABLE_EXPLANATION: RuleExplanation = { 'An RLS predicate is compiled to a row filter (ADR-0056 D4), so only the pushdown subset lowers. A ' + 'function call (`size(…)`, `has(…)`), arithmetic, a ternary, a related-record path, a list literal under ' + '`==` / `!=` 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.', + '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`.', '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 — ' + diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts index f873db7c5de..79fd87b5fa9 100644 --- a/packages/lint/src/validate-org-axis-red-lines.test.ts +++ b/packages/lint/src/validate-org-axis-red-lines.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackSchema } from '@objectstack/spec'; import { ObjectSchema } from '@objectstack/spec/data'; import { + DefaultRowScopeSchema, PermissionSetSchema, RowLevelSecurityPolicySchema, ShareRecipientType, @@ -293,6 +294,70 @@ describe('validateOrgAxisRedLines — ① no permission inheritance on the org a ).toEqual([]); }); + // [#24452] An object's default row scope is a Layer-1 policy the security + // plugin applies when the caller's held sets carry no row policy for the + // object, so a default that walks the org parent is the same second + // hierarchy. The fixture passes `ObjectSchema` (the key's own door). + it('flags an object `defaultRowScope` whose `using` walks the org parent, naming the object, not a set', () => { + const findings = validateOrgAxisRedLines({ + objects: [ + objectFixture({ + name: 'work_order', + defaultRowScope: { + name: 'work_order_child_orgs', + operation: 'select', + using: 'organization_id IN (current_user.parent_organization_id)', + }, + }), + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + where: 'default row scope "work_order_child_orgs" of object "work_order"', + path: 'objects[0].defaultRowScope.using', + }); + expect(findings[0].message).toMatch(/^RLS using reads `parent_organization_id`/); + expect(`${findings[0].where} ${findings[0].message}`).not.toMatch(/permission set/); + expect(findings[0].hint).toMatch(/accessible_org_ids/); + }); + + it('flags a default row scope `check` that walks the org parent', () => { + const findings = validateOrgAxisRedLines({ + objects: { + work_order: objectFixture({ + name: 'work_order', + defaultRowScope: { + name: 'work_order_hq_writes', + operation: 'insert', + check: "parent_organization_id = 'org_hq'", + }, + }), + }, + }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [ORG_AXIS_PERMISSION_INHERITANCE, 'objects[0].defaultRowScope.check'], + ]); + }); + + it('stays silent on a default row scope scoped by membership (CONTROL)', () => { + expect( + rules({ + objects: [ + objectFixture({ + name: 'work_order', + defaultRowScope: { + name: 'work_order_my_orgs', + operation: 'all', + using: 'organization_id IN (current_user.accessible_org_ids)', + }, + }), + ], + }), + ).toEqual([]); + }); + it('stays silent on membership-based and business-unit scoping (the sanctioned paths)', () => { expect( rules({ @@ -665,10 +730,16 @@ const READ_SURFACES: Array<{ }, { receiver: 'object', - expected: ['systemFields', 'tenancy'], + expected: ['defaultRowScope', 'name', 'systemFields', 'tenancy'], declaredBy: 'ObjectSchema', keys: () => shapeKeys(ObjectSchema), }, + { + receiver: 'scope', + expected: ['name'], + declaredBy: 'DefaultRowScopeSchema', + keys: () => shapeKeys(DefaultRowScopeSchema), + }, { receiver: 'rule', expected: ['condition', 'name', 'object', 'sharedWith'], @@ -691,15 +762,19 @@ describe('validateOrgAxisRedLines — reads only keys the spec declares (meta-te expect(read.filter((k) => !declared.includes(k))).toEqual([]); }); - it('the RLS clause list is spelled from `RowLevelSecurityPolicySchema` keys', () => { - // `policy[clause]` is a COMPUTED read, so the scan above cannot see it; the - // word list it indexes with is checked here instead. - const match = /for \(const clause of \[([^\]]*)\] as const\)/.exec(RULE_CODE); - expect(match, 'the clause loop moved — update this guard').not.toBeNull(); - const clauses = [...match![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); - expect(clauses).toEqual(['using', 'check']); - const declared = shapeKeys(RowLevelSecurityPolicySchema); - expect(clauses.filter((c) => !declared.includes(c))).toEqual([]); + it('each RLS clause list is spelled from the keys its policy schema declares', () => { + // `policy[clause]` / `scope[clause]` are COMPUTED reads, so the scan above + // cannot see them; the word lists they index with are checked here instead, + // one per loop: the permission-set policy's, then the default row scope's. + const loops = [...RULE_CODE.matchAll(/for \(const clause of \[([^\]]*)\] as const\)/g)]; + expect(loops, 'a clause loop moved — update this guard').toHaveLength(2); + const schemas = [RowLevelSecurityPolicySchema, DefaultRowScopeSchema]; + loops.forEach((loop, i) => { + const clauses = [...loop[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + expect(clauses).toEqual(['using', 'check']); + const declared = shapeKeys(schemas[i]); + expect(clauses.filter((c) => !declared.includes(c))).toEqual([]); + }); }); }); @@ -781,6 +856,21 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: unknown }> = [ ], }, }, + { + label: '① object default row scope `using`', + stack: { + objects: [ + objectFixture({ + name: 'work_order', + defaultRowScope: { + name: 'work_order_child_orgs', + operation: 'select', + using: 'organization_id IN (current_user.parent_organization_id)', + }, + }), + ], + }, + }, { label: '① sharing-rule `condition`', stack: { @@ -829,9 +919,11 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: unknown }> = [ describe('validateOrgAxisRedLines — every branch is reachable by a spec-valid stack (meta-test, #5009)', () => { it('maps every `findings.push` site in the source', () => { const sites = pushSites(); - // ① permission-set RLS, ① sharing rule, ② cross-org BU grant. The fourth — + // ① permission-set RLS, ① object default row scope (#24452), ① sharing + // rule, ② cross-org BU grant. The old object-level list — // `objects[].rowLevelSecurity[].${clause}` — is gone: no such surface. - expect(sites).toHaveLength(3); + expect(sites).toHaveLength(4); + expect(sites.map((s) => s.pathTemplate)).toContain('objects[${oIndex}].defaultRowScope.${clause}'); expect(sites.map((s) => s.pathTemplate)).not.toContain( 'objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}', ); diff --git a/packages/lint/src/validate-org-axis-red-lines.ts b/packages/lint/src/validate-org-axis-red-lines.ts index 30085fd6829..0fc9309d6aa 100644 --- a/packages/lint/src/validate-org-axis-red-lines.ts +++ b/packages/lint/src/validate-org-axis-red-lines.ts @@ -54,9 +54,11 @@ * | `permissions[]` | `ObjectStackSchema` | * | `permissions[].rowLevelSecurity[]` | `PermissionSetSchema` | * | `…[].using` / `…[].check` | `RowLevelSecurityPolicySchema` | + * | `objects[].defaultRowScope` | `ObjectSchema` | + * | `….using` / `….check` / `….name` | `DefaultRowScopeSchema` | * | `sharingRules[]` | `ObjectStackSchema` | * | `sharingRules[].condition` / `.sharedWith` / `.object` | `SharingRuleSchema` | - * | `objects[].tenancy` / `.systemFields` | `ObjectSchema` | + * | `objects[].tenancy` / `.systemFields` / `.name` | `ObjectSchema` | * * NOT read, and each for a reason that is a schema fact: * @@ -66,10 +68,9 @@ * - `sharingRules[].objectName` — `SharingRuleSchema` is `.strict()` and knows * it only as a rejected name; `object` is required, so the canonical read can * never be missing on a rule that parsed. - * - `objects[].rowLevelSecurity` / `objects[].rls` — **object-level RLS is not - * an authoring surface at all.** `ObjectSchema` declares neither key (nor - * does `authorable-surface.json` list one: the sole entry is - * `security/PermissionSet:rowLevelSecurity`), and `ObjectSchema` is + * - `objects[].rowLevelSecurity` / `objects[].rls` — **a LIST of object-level + * RLS policies is not an authoring surface at all.** `ObjectSchema` declares + * neither key, and `ObjectSchema` is * `.strict()`, so a stack carrying one does not parse — it is refused with * "Unrecognized key(s) on this object". Until #5009 this file walked that * non-existent surface for ~20 lines, complete with an `objects[N]. @@ -77,7 +78,10 @@ * cost was never the missed finding: the next author to read this rule (human * or AI) came away believing object-level RLS was a real authorization * surface and wrote more code against it (#5008 nearly did). RLS policies - * live on a permission set; that is the branch above. + * live on a permission set; that is the branch above. The one + * object-level policy `ObjectSchema` does declare is a single + * `defaultRowScope` (#24452), read in the table above and judged beside the + * permission-set branch. * * Alias tolerance belongs at the schema's refusal, not in a consumer (Prime * Directive #12) — where it also converts a loud, named rejection into a @@ -208,7 +212,8 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { // RLS policies live on a PERMISSION SET (`permissions[].rowLevelSecurity`) — // the one place `ObjectSchema` does not offer and `PermissionSetSchema` does. // See the `## Scope` table above for the object-level surface that looked - // like a second home for them and never was (#5009). + // like a second home for them and never was (#5009). The one object-level + // policy that IS declared, an object's single `defaultRowScope`, follows. const permissionSets = recordsOf(cfg.permissions); permissionSets.forEach((ps, psIndex) => { recordsOf(ps.rowLevelSecurity).forEach((policy, pIndex) => { @@ -228,6 +233,32 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { }); }); + // [#24452] An object's DEFAULT row scope (`objects[].defaultRowScope`) is + // the same policy shape without `object`, and the security plugin's + // row-policy collection applies it as a Layer-1 policy when the caller's + // held sets carry no row policy for the object. A `using` / `check` there + // that reads the org parent is the same second hierarchy, so it is judged + // the same way. It is the one object-level policy key `ObjectSchema` + // declares — see the `## Scope` table. + recordsOf(cfg.objects).forEach((object, oIndex) => { + const scope = object.defaultRowScope as AnyRec | undefined; + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return; + for (const clause of ['using', 'check'] as const) { + if (!str(scope[clause]).includes(ORG_PARENT_FIELD)) continue; + const scopeName = str(scope.name); + findings.push({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + where: `default row scope${scopeName ? ` "${scopeName}"` : ''} of object "${str(object.name) || oIndex}"`, + path: `objects[${oIndex}].defaultRowScope.${clause}`, + message: + `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the ` + + `organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`, + hint: INHERITANCE_HINT, + }); + } + }); + // Sharing rules — the predicate and the recipient may both reach for the org // parent, so both slots are scanned. // diff --git a/packages/lint/src/validate-rls-predicate-enforceability.default-row-scope.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.default-row-scope.test.ts new file mode 100644 index 00000000000..3ce34573a09 --- /dev/null +++ b/packages/lint/src/validate-rls-predicate-enforceability.default-row-scope.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24452] An object's DEFAULT row scope (`ObjectSchema.defaultRowScope`) is + * judged by the same passes as a permission set's `rowLevelSecurity[]` policy. + * + * The default is one policy of the permission-set entry shape without + * `object`, the declaring object implied. The security plugin's row-policy + * collection applies it when the caller's held sets carry no row policy for the + * object and operation, and hands it to the same `RLSCompiler` an authored + * policy reaches: a `using` that does not lower is dropped with the same WARN + * and fails closed to `RLS_DENY_FILTER`. Before this file the authoring lint + * walked `permissions[].rowLevelSecurity[]` only, so that default passed + * `os validate` and the author learned of it when rows disappeared. + * + * What is pinned: + * + * - a default whose `using` (or `check`) does not lower is reported at + * authoring time, at `objects[N].defaultRowScope.`, naming the + * object's default and never a permission set; + * - CONTROL: a default that lowers, and an object with no default, report + * nothing; + * - parity: the same predicate on a permission-set policy and on a default + * earns the same rule, severity, message and hint — one judge, two sources; + * - the engine's read-scope pass reaches a default as it reaches a policy, + * with the declaring object as its target; + * - the registry door (`runAuthoringRules`) carries it, and `os explain` + * words the drop for both sources. + * + * Every object fixture passes `ObjectSchema` first, so no case here exercises + * a shape an author cannot write. + */ + +import { describe, expect, it } from 'vitest'; +import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts'; +import { ObjectSchema } from '@objectstack/spec/data'; + +import { + validateRlsPredicateEnforceability, + RLS_PREDICATE_UNENFORCEABLE, + RLS_PREDICATE_UNKNOWN_FIELD, + RLS_PREDICATE_UNKNOWN_USER_VARIABLE, + RLS_PREDICATE_UNPARSEABLE, +} from './validate-rls-predicate-enforceability.js'; +import { runAuthoringRules } from './authoring-rules.js'; +import { explainRule } from './rule-explanations.js'; + +const LEAD_FIELDS = { + owner_id: { type: 'text', label: 'Owner' }, + status: { type: 'text', label: 'Status' }, + amount: { type: 'number', label: 'Amount' }, +} as const; + +/** A `lead` object, parsed by `ObjectSchema`, declaring `scope` as its default row scope. */ +function leadWithScope(scope: Record | undefined): Record { + const result = ObjectSchema.safeParse({ + name: 'lead', + label: 'Lead', + fields: LEAD_FIELDS, + ...(scope ? { defaultRowScope: scope } : {}), + }); + 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 Record; +} + +const stackWithScope = (scope: Record | undefined) => ({ objects: [leadWithScope(scope)] }); + +const selectScope = (using: string) => ({ name: 'lead_default', operation: 'select', using }); + +/** `os explain` text of `rule`, one string. */ +const explanationOf = (rule: string): string => explainRule(rule)?.paragraphs.join('\n') ?? ''; + +describe('validateRlsPredicateEnforceability — an object default row scope that cannot lower is RED (#24452)', () => { + it('reports a `using` that does not lower, at the default, naming the object and not a permission set', () => { + const findings = validateRlsPredicateEnforceability(stackWithScope(selectScope('size(record.tags) > 0'))); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + where: 'default row scope "lead_default" of object "lead"', + path: 'objects[0].defaultRowScope.using', + }); + expect(findings[0].message).toMatch( + /^RLS using is not lowerable \(.+\), so the policy is dropped and grants no access$/, + ); + // No permission set carries a default, so no part of the finding says one does. + expect(`${findings[0].where} ${findings[0].message} ${findings[0].hint}`).not.toMatch(/permission set/i); + }); + + it('reports a `check` that does not lower, with the write-path consequence', () => { + const findings = validateRlsPredicateEnforceability( + stackWithScope({ name: 'lead_writes', operation: 'insert', check: 'amount + 1 > 2' }), + ); + expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [RLS_PREDICATE_UNENFORCEABLE, 'objects[0].defaultRowScope.check', 'error'], + ]); + expect(findings[0].message).toMatch(/admits no write$/); + }); + + it('reads the name-keyed map shape of `objects` too', () => { + const findings = validateRlsPredicateEnforceability({ + objects: { lead: leadWithScope(selectScope("record.account.region == 'EU'")) }, + }); + expect(findings.map((f) => [f.rule, f.path, f.where])).toEqual([ + [RLS_PREDICATE_UNENFORCEABLE, 'objects[0].defaultRowScope.using', 'default row scope "lead_default" of object "lead"'], + ]); + }); + + it('CONTROL: a default that lowers, and an object with no default, report nothing', () => { + expect(validateRlsPredicateEnforceability(stackWithScope(selectScope('owner_id == current_user.id')))).toEqual([]); + expect( + validateRlsPredicateEnforceability( + stackWithScope({ name: 'lead_all', operation: 'all', using: "status == 'open' || owner_id = current_user.id" }), + ), + ).toEqual([]); + expect(validateRlsPredicateEnforceability(stackWithScope(undefined))).toEqual([]); + }); +}); + +describe('validateRlsPredicateEnforceability — one judge, two sources (#24452)', () => { + // The same predicate, on the same object, once as a permission set's policy + // and once as the object's default. Every pass that can fire on a default + // must answer identically; only `where` and `path` say which source it is. + it.each([ + ['outside the pushdown subset', 'size(record.tags) > 0', RLS_PREDICATE_UNENFORCEABLE], + ['not CEL even after the SQL bridge', 'owner_id = current_user.id AND status = 1', RLS_PREDICATE_UNPARSEABLE], + ['a column the object does not declare', 'stauts == "open"', RLS_PREDICATE_UNKNOWN_FIELD], + ['a `current_user` key nothing resolves', 'owner_id == current_user.nope', RLS_PREDICATE_UNKNOWN_USER_VARIABLE], + ['a list-holding key under `==`', 'owner_id == current_user.org_user_ids', RLS_PREDICATE_UNENFORCEABLE], + ])('%s: the default earns the verdict the permission-set policy earns', (_label, using, rule) => { + const asPolicy = validateRlsPredicateEnforceability({ + objects: [leadWithScope(undefined)], + permissions: [ + { + name: 'sales_rep', + label: 'Sales Rep', + rowLevelSecurity: [{ name: 'lead_default', object: 'lead', operation: 'select', using }], + }, + ], + }); + const asDefault = validateRlsPredicateEnforceability(stackWithScope(selectScope(using))); + + expect(asPolicy.map((f) => f.rule)).toEqual([rule]); + const verdict = ({ severity, rule: id, message, hint }: (typeof asPolicy)[number]) => ({ severity, id, message, hint }); + expect(asDefault.map(verdict)).toEqual(asPolicy.map(verdict)); + expect(asDefault.map((f) => f.path)).toEqual(['objects[0].defaultRowScope.using']); + expect(asPolicy.map((f) => f.path)).toEqual(['permissions[0].rowLevelSecurity[0].using']); + }); + + it('judges both sources in one stack, permission sets first', () => { + const findings = validateRlsPredicateEnforceability({ + objects: [leadWithScope(selectScope('size(record.tags) > 0'))], + permissions: [ + { + name: 'sales_rep', + label: 'Sales Rep', + rowLevelSecurity: [{ name: 'own', object: 'lead', operation: 'select', using: 'has(record.owner_id)' }], + }, + ], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'permissions[0].rowLevelSecurity[0].using', + 'objects[0].defaultRowScope.using', + ]); + }); +}); + +describe('validateRlsPredicateEnforceability — the engine judges a default read scope with its object (#24452)', () => { + const REFUSAL: EngineFilterJudgement = { + ok: false, + code: 'INVALID_FILTER', + status: 400, + message: "find('lead'): filter on 'amount' aims the text operator $startsWith at a declared number field.", + }; + + function recordingJudge(verdict: EngineFilterJudgement) { + const calls: Array<{ object: string; options: EngineFilterJudgementOptions | undefined }> = []; + const judgeFilter = (object: string, _where: unknown, options?: EngineFilterJudgementOptions) => { + calls.push({ object, options }); + return verdict; + }; + return { calls, judgeFilter }; + } + + it('hands a `select` default `using` to the judge with the declaring object, and reports its refusal there', () => { + const { calls, judgeFilter } = recordingJudge(REFUSAL); + const findings = validateRlsPredicateEnforceability( + stackWithScope(selectScope("amount.startsWith('1')")), + { judgeFilter: judgeFilter as never }, + ); + expect(calls).toEqual([{ object: 'lead', options: { operation: 'find' } }]); + expect(findings).toEqual([ + expect.objectContaining({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + path: 'objects[0].defaultRowScope.using', + where: 'default row scope "lead_default" of object "lead"', + message: expect.stringMatching(/^RLS using \(INVALID_FILTER \/ 400\): /), + }), + ]); + }); + + it('CONTROL: a default outside the read scope is not handed to the judge', () => { + const { calls, judgeFilter } = recordingJudge(REFUSAL); + const findings = validateRlsPredicateEnforceability( + stackWithScope({ name: 'lead_updates', operation: 'update', using: "amount.startsWith('1')" }), + { judgeFilter: judgeFilter as never }, + ); + expect(calls).toEqual([]); + expect(findings).toEqual([]); + }); +}); + +describe('validateRlsPredicateEnforceability — the registry door and `os explain` carry the default (#24452)', () => { + it('`runAuthoringRules` reports the default under the rule\'s own id', () => { + const stack = stackWithScope(selectScope('size(record.tags) > 0')); + const rls = runAuthoringRules('validate', { normalized: stack, parsed: stack }).filter((f) => + f.rule.startsWith('rls-predicate-'), + ); + expect(rls.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [RLS_PREDICATE_UNENFORCEABLE, 'objects[0].defaultRowScope.using', 'error'], + ]); + }); + + it('`os explain` words the dropped `using` for both sources', () => { + for (const rule of [RLS_PREDICATE_UNENFORCEABLE, RLS_PREDICATE_UNKNOWN_FIELD, RLS_PREDICATE_UNKNOWN_USER_VARIABLE]) { + const why = explanationOf(rule); + // A permission set's policy: the holders of THAT set lose the object. + expect(why).toMatch(/the object disappears for every holder of the permission set that carries the policy/); + // A default: applied alone, so a drop always denies the callers it applies to, and no one else. + expect(why).toMatch(/An object's `defaultRowScope` is applied only when the caller's permission sets carry no row policy/); + expect(why).toMatch(/so it is always the only applicable policy/); + expect(why).toMatch(/a caller whose permission set carries its own row policy for the object is not affected/); + } + expect(explanationOf(RLS_PREDICATE_UNENFORCEABLE)).toMatch( + /a permission set's `rowLevelSecurity` entry and an object's `defaultRowScope`/, + ); + }); +}); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 1e997b3fc7c..bbb07af6453 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -143,9 +143,22 @@ * * ## Scope * - * `permissions[].rowLevelSecurity[]` only. `rowLevelSecurity` is declared on - * `PermissionSetSchema` and nowhere else — `ObjectSchema` has no such key — and - * `permissions` is the one stack key `StackSchema` declares for permission sets. + * Two declared surfaces, and nothing else: + * + * - `permissions[].rowLevelSecurity[]`. `rowLevelSecurity` is declared on + * `PermissionSetSchema` and nowhere else — `ObjectSchema` has no such key — + * and `permissions` is the one stack key `StackSchema` declares for + * permission sets. + * - `objects[].defaultRowScope` (#24452). `ObjectSchema.defaultRowScope` is ONE + * policy of the same shape without `object` (`DefaultRowScopeSchema`), the + * declaring object implied. The security plugin's row-policy collection + * applies it when the caller's held sets carry no row policy for the object + * and operation, and from there it takes the path a held policy takes: the + * same `RLSCompiler`, the same drop, the same `RLS_DENY_FILTER`. So it is + * judged by the same passes ({@link judgePolicy}), with the declaring object + * as its target, at the same severity. Its findings name the object's + * default, never a permission set, because no permission set carries it. + * * Walking `objects[].rowLevelSecurity` or `permissionSets` "just in case" would * add a branch that no spec-valid stack can ever reach: exactly the #4984 defect, * where a red line read rejected aliases and was therefore inert for every stack @@ -1451,6 +1464,120 @@ function referenceFindings( return findings; } +/** Where one policy sits: the object it targets, how a finding names it, and its config path. */ +interface PolicySite { + /** The policy's target object (`''` when it names none). */ + object: string; + /** Human-readable location, the finding's `where`. */ + where: string; + /** Config path of the policy itself; each clause appends `.using` / `.check`. */ + path: string; +} + +/** + * 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 + * walker below calls it for a permission set's policy and for an object's + * default row scope alike, so the two sources cannot be judged differently. + */ +function judgePolicy( + graph: ObjectGraph, + policy: AnyRec, + site: PolicySite, + options: { judgeFilter?: IObjectQLEngine['judgeFilter'] }, +): RlsPredicateFinding[] { + const findings: RlsPredicateFinding[] = []; + for (const clause of ['using', 'check'] as const) { + const source = str(policy[clause]); + // Absent / blank is Zod's to judge (`using` is required on the schema); + // inventing a second complaint about the shape here would be the + // double-report this rule avoids for everything else. + if (!source.trim()) continue; + + // ── 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; + } + + 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; + } + + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + where, + path, + message: `RLS ${clause} is not lowerable (${refusalVerdict(detail)}), ${droppedClause(clause)}`, + hint: LOWERABLE_REWRITE, + }); + } + return findings; +} + /** * Gate every stack-declared RLS predicate on the ONE thing the runtime does * with it: lower it to a FilterCondition (ADR-0056 D4). @@ -1479,105 +1606,42 @@ export function validateRlsPredicateEnforceability( // re-derived here. const graph: ObjectGraph = indexObjectGraph(cfg); + // The permission sets' policies (`permissions[].rowLevelSecurity[]`). recordsOf(cfg.permissions).forEach((ps, psIndex) => { + const psName = str(ps.name) || String(psIndex); recordsOf(ps.rowLevelSecurity).forEach((policy, pIndex) => { - for (const clause of ['using', 'check'] as const) { - const source = str(policy[clause]); - // Absent / blank is Zod's to judge (`using` is required on the schema); - // inventing a second complaint about the shape here would be the - // double-report this rule avoids for everything else. - if (!source.trim()) continue; - - // ── 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 psNameEarly = str(ps.name) || String(psIndex); - const policyNameEarly = str(policy.name) || String(pIndex); - const objectEarly = str(policy.object); - const whereEarly = - `permission set "${psNameEarly}" policy "${policyNameEarly}"` + - (objectEarly ? ` on object "${objectEarly}"` : ''); - const pathEarly = `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${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' && objectEarly && READ_SCOPE_OPERATIONS.has(str(policy.operation)) - ? options.judgeFilter - : undefined; - findings.push( - ...referenceFindings(graph, source, clause, whereEarly, pathEarly, objectEarly, 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; - - const where = whereEarly; - const path = pathEarly; - - 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; - } - - 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; - } - - findings.push({ - severity: 'error', - rule: RLS_PREDICATE_UNENFORCEABLE, - where, - path, - message: `RLS ${clause} is not lowerable (${refusalVerdict(detail)}), ${droppedClause(clause)}`, - hint: LOWERABLE_REWRITE, - }); - } + const object = str(policy.object); + findings.push( + ...judgePolicy(graph, policy, { + object, + where: + `permission set "${psName}" policy "${str(policy.name) || String(pIndex)}"` + + (object ? ` on object "${object}"` : ''), + path: `permissions[${psIndex}].rowLevelSecurity[${pIndex}]`, + }, options), + ); }); }); + // [#24452] Each object's default row scope (`objects[].defaultRowScope`): + // the same policy shape without `object`, which the declaring object + // implies. The security plugin's row-policy collection hands it to the same + // `RLSCompiler` an authored policy reaches, so it is judged by the same + // passes, with the declaring object as its target — see this file's + // `## Scope`. + recordsOf(cfg.objects).forEach((obj, oIndex) => { + const scope = obj.defaultRowScope; + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return; + const object = str(obj.name); + const scopeName = str((scope as AnyRec).name); + findings.push( + ...judgePolicy(graph, scope as AnyRec, { + object, + where: `default row scope${scopeName ? ` "${scopeName}"` : ''} of object "${object || String(oIndex)}"`, + path: `objects[${oIndex}].defaultRowScope`, + }, options), + ); + }); + return findings; } From 3a4ddd53db809f1f3ac0b089046765ad16945a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:24:41 +0000 Subject: [PATCH 2/3] fix(spec): say the CLI authoring checks judge an object's defaultRowScope predicate Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24452-default-row-scope-lint.md | 2 ++ packages/spec/src/data/object.zod.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.changeset/24452-default-row-scope-lint.md b/.changeset/24452-default-row-scope-lint.md index 4ccf4f6930d..0c7f084dbd8 100644 --- a/.changeset/24452-default-row-scope-lint.md +++ b/.changeset/24452-default-row-scope-lint.md @@ -1,5 +1,6 @@ --- "@objectstack/lint": patch +"@objectstack/spec": patch --- fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope` @@ -9,3 +10,4 @@ Clause-②: no - **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`. - **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new. - **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to. +- **The key's own text.** `ObjectSchema.defaultRowScope`'s description said authoring checks do not judge its predicate; it now says `os validate`, `os build` and `os lint` judge it as they judge a permission set's policy, and that saving the object through the server does not run that check. diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7ae6106fe87..480ff9b7363 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2990,8 +2990,10 @@ const ObjectSchemaBase = strictObject( * * A `using` the row-filter compiler cannot lower is dropped with the * compiler's WARN and fails closed to zero rows, exactly as an authored - * policy's does; the authoring lint (`RLS_PREDICATE_UNENFORCEABLE`) walks - * permission sets only and does not judge this key. Explain names an applied + * policy's does. The CLI authoring lint judges this key as it judges a + * permission set's policy (`RLS_PREDICATE_UNENFORCEABLE` and its sibling + * ids, #24452); the runtime publish gate runs that rule on `permission` + * writes only, so an `object` save is not judged there. Explain names an applied * default as the object's own declaration, with its `label` and * `description`. The writers are to be the platform identity objects * (#24341); no object in this tree declares the key yet. @@ -3007,9 +3009,10 @@ const ObjectSchemaBase = strictObject( + 'bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has ' + 'without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` ' + 'decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it ' - + 'governs return no rows, with one warning in the server log. Authoring checks do not judge this ' - + 'predicate, so keep it inside the lowerable subset. Explain names an applied default as the object\'s ' - + 'own, not a permission set\'s.', + + 'governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` ' + + 'judge this predicate as they judge a permission set\'s policy and report one that cannot lower; saving ' + + 'the object through the server does not run that check, so keep it inside the lowerable subset. Explain ' + + 'names an applied default as the object\'s own, not a permission set\'s.', ), /** From 0db86b5479f439ce40a92e630bb16892308aee83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:54:45 +0000 Subject: [PATCH 3/3] docs(spec): regenerate the reference pages for the defaultRowScope describe Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/system/migration.mdx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index c29d3e597a9..c1440099e69 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -969,7 +969,7 @@ Metadata query with filtering, sorting, and pagination | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5d9983bde2d..bf1245a1591 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -173,7 +173,7 @@ const result = ApiMethod.parse(data); | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 152f875fb74..9b491b4cca8 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -355,7 +355,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -649,7 +649,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |