From 4199782ae8082b4d937c619e477ff9c950e6aebc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:51:53 +0000 Subject: [PATCH 1/4] feat(lint): warn when a writable external object refuses every member write An external object on an owner-keyed sharing model whose owner column no remote row serves refuses every member edit and delete (403): record sharing reads owner_id, the ownership floor reads created_by == caller, and neither has storage. security-external-owner-unmapped is an advisory on os validate / build / lint naming the two ways out (public_read_write plus a beforeUpdate hook, or owner_id mapped through external.columnMap). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- .../27332-external-owner-unmapped-warning.md | 15 ++ packages/lint/src/authoring-rules.ts | 17 ++ packages/lint/src/index.ts | 11 ++ packages/lint/src/rule-explanations.ts | 37 ++++ .../validate-external-owner-sharing.test.ts | 151 ++++++++++++++++ .../src/validate-external-owner-sharing.ts | 170 ++++++++++++++++++ .../validate-sharing-rule-enforceability.ts | 2 +- 7 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 .changeset/27332-external-owner-unmapped-warning.md create mode 100644 packages/lint/src/validate-external-owner-sharing.test.ts create mode 100644 packages/lint/src/validate-external-owner-sharing.ts diff --git a/.changeset/27332-external-owner-unmapped-warning.md b/.changeset/27332-external-owner-unmapped-warning.md new file mode 100644 index 00000000000..5e86419ed40 --- /dev/null +++ b/.changeset/27332-external-owner-unmapped-warning.md @@ -0,0 +1,15 @@ +--- +'@objectstack/lint': patch +--- + +feat(lint): `security-external-owner-unmapped` warns when a writable external object refuses every member write (#27332) + +Clause-②: no (a warning narrows nothing; no accepted metadata shape moves) +Metadata-protocol: none + +An external object (ADR-0015) on an owner-keyed sharing model (`private`, `public_read`, or none on a custom object) refuses every member edit and delete with 403 `PERMISSION_DENIED`, even for a member whose permission set grants `allowEdit`. Before the write, record sharing reads the row's `owner_id` and the platform ownership floor reads `created_by` equal to the caller, and no remote row carries either column. `os validate`, `os build` and `os lint` now say so with a warning on the object. + +- The warning names two ways out. One: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook. Two: map the remote owner column to `owner_id` through `external.columnMap` and declare `owner_id` in `fields`. +- `ownership: 'none'` (or `'org'` / `'business_unit'`) is not a way out. It drops `owner_id`, and the floor's `created_by` check refuses the same write, so the warning still fires and names `created_by`. Mapping the remote creator column to `created_by` silences it. +- It skips a read-only binding (`external.writable` not `true`), `public_read_write`, `controlled_by_parent`, and an object whose owner column the author declares. +- It is a warning, never an error: `defineStack` and every publish door accept the same stacks as before. `os explain security-external-owner-unmapped` explains the two checks, and why `public_read_write` opens edits but not deletes. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 1697a7c43c4..050837c2cdb 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -137,6 +137,7 @@ import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; import { validateSecurityPosture, validateSecurityRoleWord } from './validate-security-posture.js'; import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; +import { validateExternalOwnerSharing } from './validate-external-owner-sharing.js'; import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js'; import { validateMembershipKeyDeclarations, @@ -2330,6 +2331,22 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ + 'never run at a door should not claim it.', run: (stack) => validateSharingRuleEnforceability(stack), }, + // A writable ADR-0015 external object on an owner-keyed sharing model whose + // owner column no remote row serves: record sharing reads `owner_id`, the + // platform ownership floor reads `created_by == caller`, neither has storage, + // so every member edit and delete is refused (403). Advisory: the stack is + // valid and an administrator still writes; a refusal would narrow the + // published accept set. + { + name: 'validateExternalOwnerSharing', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-external-owner-sharing.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, + run: (stack) => validateExternalOwnerSharing(stack), + }, // #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate, // which had never been wired to anything: `isSupportedRlsExpression` existed // solely so an authoring command could reject a predicate the runtime drops, diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d5a7650922b..542241bd752 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -380,6 +380,17 @@ export type { SharingRuleEnforceabilitySeverity, } from './validate-sharing-rule-enforceability.js'; +// A writable external object on an owner-keyed sharing model whose owner column +// no remote row serves refuses every member edit and delete (403). +export { + validateExternalOwnerSharing, + SECURITY_EXTERNAL_OWNER_UNMAPPED, +} from './validate-external-owner-sharing.js'; +export type { + ExternalOwnerSharingFinding, + ExternalOwnerSharingSeverity, +} from './validate-external-owner-sharing.js'; + // #4983 — the sibling surface, and ADR-0056 D4's gate finally wired. An RLS // `using` / `check` the runtime cannot compile is DROPPED (and, when it is the // only applicable policy, replaced by the deny sentinel), so the policy reads diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 42059d4a587..204f8f38b98 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -730,6 +730,42 @@ const SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION: RuleExplanation = { ], }; +const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = { + rule: 'security-external-owner-unmapped', + covers: 'why members cannot write an external object', + paragraphs: [ + 'Before a member updates or deletes one record, the platform runs two row-level checks. Record ' + + 'sharing, on a `private` or `public_read` object that carries an `owner_id` column, asks for the ' + + 'row\'s `owner_id` and admits the owner (widened by write depth, an `edit` share or Modify All ' + + 'Data). The platform ownership floor, `created_by` equal to the caller, stays in force unless record ' + + 'sharing admits the write or the object declares `sharingModel: \'public_read_write\'`.', + 'The registry adds `owner_id` and `created_by` to an external object as it does to every object, but ' + + 'the remote database owns the table and the platform creates no column for either. So the remote row ' + + 'answers no `owner_id` (record sharing refuses) and no `created_by` equal to the caller (the floor ' + + 'refuses). A member whose permission set grants `allowEdit` is refused every edit and every delete ' + + 'with 403 `PERMISSION_DENIED`; only an administrator can write. An object with no `sharingModel` is ' + + 'judged as \'private\', the value the runtime falls back to.', + '`ownership: \'none\'` (or `\'org\'`, `\'business_unit\'`) is not a way out: without `owner_id` record ' + + 'sharing stops judging the row, and the floor\'s `created_by` check refuses the same write. Turning ' + + 'the audit columns off (`systemFields.audit: false`) does not help either: the floor then has no ' + + 'column to compare and refuses every row.', + 'There are two ways out. Declare `sharingModel: \'public_read_write\'`: every member who holds ' + + '`allowEdit` may then edit, and a `beforeUpdate` hook decides who really may. Or serve the column ' + + 'the check reads: map the remote owner column to `owner_id` through `external.columnMap` and declare ' + + '`owner_id` in `fields`, so the owner edits and deletes the row and other members are refused. ' + + 'Declaring the field matters for reads too: without it the platform treats `owner_id` as its own ' + + 'unstored column and does not scope reads by owner. On an object without `owner_id`, map the remote ' + + 'creator column to `created_by` instead, so the creator writes the row.', + 'Deletes are not opened by `public_read_write`: that model opens edits, and the floor keeps deletes ' + + 'to the row\'s `created_by`. On an external object that column has no storage either, so member ' + + 'deletes stay refused unless `created_by` is mapped or an authored policy or share grants them.', + 'The rule judges only a writable binding (`external.writable: true`) on a model that keys writes to ' + + 'an owner (`private`, `public_read`, or none on a custom object). `controlled_by_parent` is not judged: ' + + 'the master record\'s gate decides its writes. It is a warning, not an error: the stack is valid and ' + + 'an administrator can still write.', + ], +}; + const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = { rule: 'security-fls-unknown-field', covers: 'how the runtime resolves a field-permission key', @@ -5150,6 +5186,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION.rule]: SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION, [SECURITY_CBP_NO_RELATION_EXPLANATION.rule]: SECURITY_CBP_NO_RELATION_EXPLANATION, [SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION.rule]: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION, + [SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION.rule]: SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION, [SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION.rule]: SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION, [SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION.rule]: SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION, [SECURITY_WILDCARD_VAMA_EXPLANATION.rule]: SECURITY_WILDCARD_VAMA_EXPLANATION, diff --git a/packages/lint/src/validate-external-owner-sharing.test.ts b/packages/lint/src/validate-external-owner-sharing.test.ts new file mode 100644 index 00000000000..b1a27e8ed94 --- /dev/null +++ b/packages/lint/src/validate-external-owner-sharing.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { defineStack } from '@objectstack/spec'; +import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; +import { + SECURITY_EXTERNAL_OWNER_UNMAPPED, + validateExternalOwnerSharing, +} from './validate-external-owner-sharing.js'; + +/** + * The two external objects of `packages/runtime/src/plugin-driver-external-object.spike.test.ts`, + * copied verbatim (fields trimmed to what the rule reads). That spike measures the runtime half: + * on `spike_owned` (no `sharingModel`) a member holding `allowEdit` gets 403 on every PATCH, + * step (e3); on `spike_node` (`public_read_write`) the same member's write reaches the driver. + */ +const SPIKE_STACK = defineStack({ + manifest: { id: 'com.spike.plugin-driver', name: 'Plugin driver spike', version: '1.0.0', type: 'app' }, + datasources: [ + { + name: 'spike_tree', + label: 'Spike tree (plugin driver)', + driver: 'spike_tree', + schemaMode: 'external', + config: {}, + external: { allowWrites: true, validation: { onMismatch: 'fail', checkOnBoot: false } }, + }, + ], + objects: [ + { + name: 'spike_node', + label: 'Spike node', + datasource: 'spike_tree', + external: { remoteName: 'nodes', writable: true }, + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, + }, + { + name: 'spike_owned', + label: 'Spike owned', + datasource: 'spike_tree', + external: { remoteName: 'owned', writable: true }, + fields: { name: { type: 'text', label: 'Name' } }, + }, + ], +}) as unknown as Record; + +const NAME_FIELD = { name: { type: 'text', label: 'Name' } }; + +/** One external object, writable unless the override says otherwise. */ +function ext(overrides: Record = {}, external: Record = {}) { + return { + name: 'remote_row', + label: 'Remote row', + datasource: 'remote', + external: { remoteName: 'rows', writable: true, ...external }, + fields: NAME_FIELD, + ...overrides, + }; +} + +const findingsFor = (...objects: unknown[]) => validateExternalOwnerSharing({ objects }); + +describe('security-external-owner-unmapped — the spike shapes', () => { + const findings = validateExternalOwnerSharing(SPIKE_STACK); + + it('spike_owned (the default sharing model) gets one warning naming the owner_id column', () => { + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SECURITY_EXTERNAL_OWNER_UNMAPPED, + where: 'object "spike_owned"', + path: 'objects[1].sharingModel', + }); + expect(findings[0]!.message).toContain("'owner_id'"); + }); + + it('spike_node (public_read_write) gets none', () => { + expect(findings.filter((f) => f.where === 'object "spike_node"')).toEqual([]); + }); + + it('`os validate` reports it as an advisory, never a gating error', () => { + const all = runAuthoringRules('validate', { normalized: SPIKE_STACK, parsed: SPIKE_STACK }); + const { errors, advisories } = splitBySeverity(all); + expect(errors.filter((f) => f.rule === SECURITY_EXTERNAL_OWNER_UNMAPPED)).toEqual([]); + expect(advisories.filter((f) => f.rule === SECURITY_EXTERNAL_OWNER_UNMAPPED).map((f) => f.where)).toEqual([ + 'object "spike_owned"', + ]); + }); +}); + +describe('security-external-owner-unmapped — which objects it judges', () => { + it('warns on an owner-keyed model: unset, private and public_read', () => { + for (const sharingModel of [undefined, 'private', 'public_read']) { + const obj = sharingModel === undefined ? ext() : ext({ sharingModel }); + expect(findingsFor(obj).map((f) => f.rule), String(sharingModel)).toEqual([SECURITY_EXTERNAL_OWNER_UNMAPPED]); + } + }); + + it('stays silent on public_read_write and controlled_by_parent', () => { + expect(findingsFor(ext({ sharingModel: 'public_read_write' }))).toEqual([]); + expect(findingsFor(ext({ sharingModel: 'controlled_by_parent' }))).toEqual([]); + }); + + it('stays silent on a read-only binding', () => { + expect(findingsFor(ext({ sharingModel: 'private' }, { writable: false }))).toEqual([]); + expect(findingsFor({ ...ext({ sharingModel: 'private' }), external: { remoteName: 'rows' } })).toEqual([]); + }); + + it('stays silent on an object the platform stores, and on a system object with no sharingModel', () => { + const { external: _external, ...local } = ext({ sharingModel: 'private' }); + expect(findingsFor(local)).toEqual([]); + expect(findingsFor(ext({ isSystem: true }))).toEqual([]); + }); + + it('stays silent when a remote column serves owner_id: mapped through columnMap, or declared', () => { + expect(findingsFor(ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } }))).toEqual([]); + const declared = ext({ + sharingModel: 'private', + fields: { ...NAME_FIELD, owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' } }, + }); + expect(findingsFor(declared)).toEqual([]); + }); + + it('a columnMap that maps only created_by does not serve the owner check', () => { + const findings = findingsFor(ext({ sharingModel: 'private' }, { columnMap: { author: 'created_by' } })); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("'owner_id'"); + }); +}); + +describe("security-external-owner-unmapped — ownership: 'none' moves the refusal to the floor", () => { + it('warns on an object with no owner_id, naming created_by', () => { + for (const ownership of ['none', 'org', 'business_unit']) { + const findings = findingsFor(ext({ sharingModel: 'private', ownership })); + expect(findings, ownership).toHaveLength(1); + expect(findings[0]!.message, ownership).toContain("'created_by'"); + } + }); + + it('warns when the audit columns are off, and is silent when columnMap maps created_by', () => { + const noAudit = findingsFor(ext({ sharingModel: 'private', ownership: 'none', systemFields: { audit: false } })); + expect(noAudit).toHaveLength(1); + expect(noAudit[0]!.message).toContain("'created_by'"); + expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none' }, { columnMap: { author: 'created_by' } }))).toEqual([]); + }); + + it('is silent on public_read_write whatever the ownership tier', () => { + expect(findingsFor(ext({ sharingModel: 'public_read_write', ownership: 'none' }))).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-external-owner-sharing.ts b/packages/lint/src/validate-external-owner-sharing.ts new file mode 100644 index 00000000000..349a978cd4a --- /dev/null +++ b/packages/lint/src/validate-external-owner-sharing.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A writable external object (ADR-0015) on an owner-keyed sharing model whose + * owner column no remote row serves: every member edit and delete is refused. + * + * ## The mechanism this warns about + * + * Before a by-id update or delete, the platform runs two row-level checks + * (measured through `PATCH /data/:object/:id` and `DELETE` over a plugin + * driver, the spike `plugin-driver-external-object.spike.test.ts` step e3): + * + * - **record sharing** (`plugin-sharing` `checkEdit` / `checkDelete`) asks the + * remote for the row's `owner_id` when the sharing model is `private` or + * `public_read` and the object carries an `owner_id` column; + * - **the platform ownership floor** (`owner_only_writes` / + * `owner_only_deletes`, `created_by == current_user.id`) stays in force + * unless record sharing answers `allow` or the model is + * `public_read_write` (which opens updates, never deletes). + * + * The registry injects `owner_id` and `created_by` into an external object + * too, but provisions no storage for them, so no remote row carries either: + * `owner_id` reads as null (sharing answers `deny`) and `created_by` never + * equals the caller (the floor refuses). A member whose permission set grants + * `allowEdit` gets 403 `PERMISSION_DENIED` on every row, and only an + * administrator can write. `ownership: 'none'` (or `'org'` / + * `'business_unit'`) is NOT a way out: it withholds `owner_id`, so record + * sharing abstains, and the floor's `created_by` read refuses the same write + * (measured: 403 on update and delete). + * + * ## The two arms + * + * 1. The object carries `owner_id` (`ownership: 'user'`, the default) as the + * platform's unprovisioned anchor, and `external.columnMap` maps no remote + * column to it ⇒ record sharing refuses. + * 2. The object carries no `owner_id` (another `ownership` tier) and its + * `created_by` is the unprovisioned anchor that `external.columnMap` does + * not map, or is absent (`systemFields.audit: false`, where the floor + * compiles to a deny) ⇒ the floor refuses. + * + * A column the author DECLARES in `fields` is the author's (they vouch for a + * remote column of that name, or map one to it) and silences its arm — the + * same provenance the runtime guards read (`resolveInjectedColumnProvenance`, + * `@objectstack/spec/data`). + * + * ## Deliberately not judged + * + * - A read-only binding (`external.writable` not `true`): no member can edit + * it whatever the sharing model says, so the refusal is not this one. + * - `public_read_write` and `controlled_by_parent`: the first lifts the floor + * for updates (who may edit is a hook's decision), the second hands row + * writes to the master's gate. Deletes on a `public_read_write` external + * object still meet the floor; that is the OWD's own boundary + * (`owdOpenWritesCoversOperation`, plugin-security), explained in the + * rule's `os explain` entry, and not reported here. + * - A system object with no `sharingModel` (`isSystem`, `sys_*`): its unset + * model is public (`effectiveSharingModelOf`). + * + * Advisory (warning), never a refusal: the stack is valid, an administrator + * can still write, and refusing it at `defineStack` would narrow the published + * accept set. + */ + +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { recordsOf } from './object-graph.js'; +import { effectiveSharingModelOf } from './validate-sharing-rule-enforceability.js'; + +export type ExternalOwnerSharingSeverity = 'warning'; + +export interface ExternalOwnerSharingFinding { + severity: ExternalOwnerSharingSeverity; + rule: string; + /** Human-readable location, e.g. `object "berth_slot"`. */ + where: string; + /** Config path, e.g. `objects[3].sharingModel`. */ + path: string; + message: string; + hint: string; +} + +// Rule id (registry entry). +export const SECURITY_EXTERNAL_OWNER_UNMAPPED = 'security-external-owner-unmapped'; + +type AnyRec = Record; + +const OWNER_COLUMN = 'owner_id'; +const CREATOR_COLUMN = 'created_by'; + +const PUBLIC_READ_WRITE_WAY_OUT = + "declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook"; + +/** Does `external.columnMap` (remote column → local field) map some remote column to `field`? */ +function columnMapServes(external: AnyRec, field: string): boolean { + const map = external.columnMap; + if (!map || typeof map !== 'object' || Array.isArray(map)) return false; + return Object.values(map as AnyRec).includes(field); +} + +/** + * The column whose missing storage refuses every member write on this object, + * and whether the injection supplies it at all — or `undefined` when a remote + * column serves the check that keys this object's writes. + */ +function unservedOwnershipColumn( + obj: AnyRec, + external: AnyRec, +): { column: string; injected: boolean } | undefined { + const owner = resolveInjectedColumnProvenance(obj, OWNER_COLUMN); + if (owner === 'author' || owner === 'injected-provisioned') return undefined; + if (owner === 'injected-unprovisioned') { + return columnMapServes(external, OWNER_COLUMN) ? undefined : { column: OWNER_COLUMN, injected: true }; + } + // No `owner_id` at all: record sharing abstains and the floor decides. + const creator = resolveInjectedColumnProvenance(obj, CREATOR_COLUMN); + if (creator === 'author' || creator === 'injected-provisioned') return undefined; + if (creator === 'injected-unprovisioned') { + return columnMapServes(external, CREATOR_COLUMN) ? undefined : { column: CREATOR_COLUMN, injected: true }; + } + return { column: CREATOR_COLUMN, injected: false }; +} + +/** + * Flag every writable external object on an owner-keyed sharing model whose + * ownership column no remote column serves. Returns the findings (empty = + * clean); the CLI folds them in as advisory warnings. + */ +export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] { + const out: ExternalOwnerSharingFinding[] = []; + if (!stack || typeof stack !== 'object') return out; + const objects = recordsOf(stack.objects); + + objects.forEach((obj, i) => { + const external = obj.external; + if (!external || typeof external !== 'object' || Array.isArray(external)) return; + if ((external as AnyRec).writable !== true) return; + const model = effectiveSharingModelOf(obj); + if (model === 'public') return; + const unserved = unservedOwnershipColumn(obj, external as AnyRec); + if (!unserved) return; + + const name = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const declared = typeof obj.sharingModel === 'string' ? `'${obj.sharingModel}'` : "'private' (the default)"; + const check = + unserved.column === OWNER_COLUMN + ? 'record sharing reads it' + : 'with no owner_id, the ownership floor reads created_by == the caller'; + const mapWayOut = + unserved.column === OWNER_COLUMN + ? 'map the remote owner column to owner_id through external.columnMap and declare owner_id in fields' + : unserved.injected + ? 'map the remote creator column to created_by through external.columnMap' + : 'let the platform inject the audit columns (systemFields) and map the remote creator column to created_by through external.columnMap'; + + out.push({ + severity: 'warning', + rule: SECURITY_EXTERNAL_OWNER_UNMAPPED, + where: `object "${name}"`, + path: `objects[${i}].sharingModel`, + // [#22161] One verdict sentence and one fix; the two checks, why + // `ownership: 'none'` is no way out and what deletes meet are + // `os explain security-external-owner-unmapped`. + message: + `external object on sharing model ${declared}, but no remote column serves '${unserved.column}' ` + + `(${check} before every edit and delete), so every member write is refused (403)`, + hint: `Either ${PUBLIC_READ_WRITE_WAY_OUT}, or ${mapWayOut}`, + }); + }); + + return out; +} diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index fd5711be3dc..3b457768095 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.ts @@ -385,7 +385,7 @@ function crossClassFinding( * deliberately does NOT model is the part of the runtime verdict that is not in * the metadata — see the `## What this rule deliberately does NOT do` section. */ -function effectiveSharingModelOf(obj: AnyRec): 'private' | 'read' | 'public' { +export function effectiveSharingModelOf(obj: AnyRec): 'private' | 'read' | 'public' { const m = obj.sharingModel; if (m === 'private') return 'private'; if (m === 'public_read') return 'read'; From b0e37b9d33567e06c4366bd2633749773ef17861 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:17:12 +0000 Subject: [PATCH 2/4] docs: the author-time rule count in four CLI transcripts reads 56 The new advisory rule raises the count authoringRulesFor('validate') and ('build') derive; check:docs-transcript-drift holds the transcripts to it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 44429ab3713..39ce927ffb7 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 (55)... + → Running author-time rules (56)... → 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 0426a2d887d..b67af7fc897 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -752,7 +752,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 (55)... + → Running author-time rules (56)... → 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 9884c08e4c3..22dcf5948ee 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 (55)... + → Running author-time rules (56)... ✗ 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 b7455b0d826..146b302cfa1 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (55)... + → Running author-time rules (56)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... From ee143ecb2df17dad7bcd57e4e70e245e8bddf462 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:33:49 +0000 Subject: [PATCH 3/4] fix(lint): the external-owner warning reads a declared field, not columnMap external.columnMap is applied by the built-in SQL drivers, not by the engine, so on a plugin driver a columnMap entry for owner_id serves no check. The warning is now silenced only by a declared owner_id (or created_by on an object without owner_id), words a columnMap-only entry on its own, and names the way out driver-neutrally: declare the field and have the driver return the remote owner under that name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- .../27332-external-owner-unmapped-warning.md | 7 +- packages/lint/src/rule-explanations.ts | 17 +-- .../validate-external-owner-sharing.test.ts | 30 ++++-- .../src/validate-external-owner-sharing.ts | 101 ++++++++++-------- 4 files changed, 90 insertions(+), 65 deletions(-) diff --git a/.changeset/27332-external-owner-unmapped-warning.md b/.changeset/27332-external-owner-unmapped-warning.md index 5e86419ed40..77e684a0d06 100644 --- a/.changeset/27332-external-owner-unmapped-warning.md +++ b/.changeset/27332-external-owner-unmapped-warning.md @@ -9,7 +9,8 @@ Metadata-protocol: none An external object (ADR-0015) on an owner-keyed sharing model (`private`, `public_read`, or none on a custom object) refuses every member edit and delete with 403 `PERMISSION_DENIED`, even for a member whose permission set grants `allowEdit`. Before the write, record sharing reads the row's `owner_id` and the platform ownership floor reads `created_by` equal to the caller, and no remote row carries either column. `os validate`, `os build` and `os lint` now say so with a warning on the object. -- The warning names two ways out. One: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook. Two: map the remote owner column to `owner_id` through `external.columnMap` and declare `owner_id` in `fields`. -- `ownership: 'none'` (or `'org'` / `'business_unit'`) is not a way out. It drops `owner_id`, and the floor's `created_by` check refuses the same write, so the warning still fires and names `created_by`. Mapping the remote creator column to `created_by` silences it. -- It skips a read-only binding (`external.writable` not `true`), `public_read_write`, `controlled_by_parent`, and an object whose owner column the author declares. +- The warning names two ways out. One: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook. Two: declare `owner_id` in `fields` and have the driver return the remote owner under that name. A built-in SQL driver does that through `external.columnMap`; `columnMap` is not applied by the engine, so a plugin driver must return the field itself. +- A `columnMap` entry for `owner_id` without the declared field still warns, worded for it: it works on a built-in SQL driver only, and leaves reads unscoped by owner there. +- `ownership: 'none'` (or `'org'` / `'business_unit'`) is not a way out. It drops `owner_id`, and the floor's `created_by` check refuses the same write, so the warning still fires and names `created_by`. Declaring `created_by` (served by the driver) silences it. +- It skips a read-only binding (`external.writable` not `true`), `public_read_write`, `controlled_by_parent`, and an object that declares the column its check reads. - It is a warning, never an error: `defineStack` and every publish door accept the same stacks as before. `os explain security-external-owner-unmapped` explains the two checks, and why `public_read_write` opens edits but not deletes. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 204f8f38b98..a56a90e87b7 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -751,14 +751,19 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = { 'column to compare and refuses every row.', 'There are two ways out. Declare `sharingModel: \'public_read_write\'`: every member who holds ' + '`allowEdit` may then edit, and a `beforeUpdate` hook decides who really may. Or serve the column ' + - 'the check reads: map the remote owner column to `owner_id` through `external.columnMap` and declare ' + - '`owner_id` in `fields`, so the owner edits and deletes the row and other members are refused. ' + - 'Declaring the field matters for reads too: without it the platform treats `owner_id` as its own ' + - 'unstored column and does not scope reads by owner. On an object without `owner_id`, map the remote ' + - 'creator column to `created_by` instead, so the creator writes the row.', + 'the check reads: declare `owner_id` in `fields` and have the driver return the remote owner under ' + + 'that name, so the owner edits and deletes the row and other members are refused. A built-in SQL ' + + 'driver does that through `external.columnMap` (remote column to `owner_id`); `columnMap` is applied ' + + 'by those drivers, not by the engine, so a plugin driver must return the field itself. Declaring ' + + 'the field matters for reads too: the platform\'s own `owner_id` is treated as unstored, and reads ' + + 'are not scoped by owner. On an object without `owner_id`, declare `created_by` the same way instead, ' + + 'so the creator writes the row.', + 'A `columnMap` entry for the column without a declared field is reported too, with its own wording: ' + + 'it serves the check on a built-in SQL driver and does nothing on a plugin driver, and the ' + + 'declaration is what holds on both.', 'Deletes are not opened by `public_read_write`: that model opens edits, and the floor keeps deletes ' + 'to the row\'s `created_by`. On an external object that column has no storage either, so member ' + - 'deletes stay refused unless `created_by` is mapped or an authored policy or share grants them.', + 'deletes stay refused unless the driver returns `created_by` or an authored policy or share grants them.', 'The rule judges only a writable binding (`external.writable: true`) on a model that keys writes to ' + 'an owner (`private`, `public_read`, or none on a custom object). `controlled_by_parent` is not judged: ' + 'the master record\'s gate decides its writes. It is a warning, not an error: the stack is valid and ' + diff --git a/packages/lint/src/validate-external-owner-sharing.test.ts b/packages/lint/src/validate-external-owner-sharing.test.ts index b1a27e8ed94..8d57688da13 100644 --- a/packages/lint/src/validate-external-owner-sharing.test.ts +++ b/packages/lint/src/validate-external-owner-sharing.test.ts @@ -113,13 +113,17 @@ describe('security-external-owner-unmapped — which objects it judges', () => { expect(findingsFor(ext({ isSystem: true }))).toEqual([]); }); - it('stays silent when a remote column serves owner_id: mapped through columnMap, or declared', () => { - expect(findingsFor(ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } }))).toEqual([]); - const declared = ext({ - sharingModel: 'private', - fields: { ...NAME_FIELD, owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' } }, - }); - expect(findingsFor(declared)).toEqual([]); + it('stays silent when the author declares owner_id, with or without a columnMap entry for it', () => { + const fields = { ...NAME_FIELD, owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' } }; + expect(findingsFor(ext({ sharingModel: 'private', fields }))).toEqual([]); + expect(findingsFor(ext({ sharingModel: 'private', fields }, { columnMap: { assignee: 'owner_id' } }))).toEqual([]); + }); + + it('a columnMap entry alone does not silence it: only a built-in SQL driver applies columnMap', () => { + const findings = findingsFor(ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } })); + expect(findings.map((f) => f.rule)).toEqual([SECURITY_EXTERNAL_OWNER_UNMAPPED]); + expect(findings[0]!.message).toContain("'owner_id'"); + expect(findings[0]!.message).toContain('columnMap'); }); it('a columnMap that maps only created_by does not serve the owner check', () => { @@ -138,11 +142,19 @@ describe("security-external-owner-unmapped — ownership: 'none' moves the refus } }); - it('warns when the audit columns are off, and is silent when columnMap maps created_by', () => { + it('warns when the audit columns are off, and on a columnMap entry for created_by alone', () => { const noAudit = findingsFor(ext({ sharingModel: 'private', ownership: 'none', systemFields: { audit: false } })); expect(noAudit).toHaveLength(1); expect(noAudit[0]!.message).toContain("'created_by'"); - expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none' }, { columnMap: { author: 'created_by' } }))).toEqual([]); + const mapped = findingsFor(ext({ sharingModel: 'private', ownership: 'none' }, { columnMap: { author: 'created_by' } })); + expect(mapped).toHaveLength(1); + expect(mapped[0]!.message).toContain("'created_by'"); + expect(mapped[0]!.message).toContain('columnMap'); + }); + + it('is silent when the author declares created_by', () => { + const fields = { ...NAME_FIELD, created_by: { type: 'lookup', reference: 'sys_user', label: 'Created by' } }; + expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none', fields }))).toEqual([]); }); it('is silent on public_read_write whatever the ownership tier', () => { diff --git a/packages/lint/src/validate-external-owner-sharing.ts b/packages/lint/src/validate-external-owner-sharing.ts index 349a978cd4a..1cbc9bdebd9 100644 --- a/packages/lint/src/validate-external-owner-sharing.ts +++ b/packages/lint/src/validate-external-owner-sharing.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A writable external object (ADR-0015) on an owner-keyed sharing model whose - * owner column no remote row serves: every member edit and delete is refused. + * A writable external object (ADR-0015) on an owner-keyed sharing model that + * declares no ownership column a remote row serves: every member edit and + * delete is refused. * * ## The mechanism this warns about * @@ -11,7 +12,7 @@ * driver, the spike `plugin-driver-external-object.spike.test.ts` step e3): * * - **record sharing** (`plugin-sharing` `checkEdit` / `checkDelete`) asks the - * remote for the row's `owner_id` when the sharing model is `private` or + * driver for the row's `owner_id` when the sharing model is `private` or * `public_read` and the object carries an `owner_id` column; * - **the platform ownership floor** (`owner_only_writes` / * `owner_only_deletes`, `created_by == current_user.id`) stays in force @@ -19,7 +20,7 @@ * `public_read_write` (which opens updates, never deletes). * * The registry injects `owner_id` and `created_by` into an external object - * too, but provisions no storage for them, so no remote row carries either: + * too, but provisions no storage for them, so a remote row carries neither: * `owner_id` reads as null (sharing answers `deny`) and `created_by` never * equals the caller (the floor refuses). A member whose permission set grants * `allowEdit` gets 403 `PERMISSION_DENIED` on every row, and only an @@ -28,20 +29,28 @@ * sharing abstains, and the floor's `created_by` read refuses the same write * (measured: 403 on update and delete). * + * What lets the owner write is the DRIVER returning the remote owner under the + * field name the check reads. `external.columnMap` is applied by the built-in + * SQL drivers, not by the engine: on a plugin driver an entry mapping a remote + * column to `owner_id` changes nothing, and the driver must return the field + * itself. So the author's vouch this rule reads is a DECLARED field — the same + * provenance the runtime guards read (`resolveInjectedColumnProvenance`, + * `@objectstack/spec/data`) — never a `columnMap` entry alone. A declared + * `owner_id` also scopes reads by owner; the platform's own injected one does + * not (plugin-sharing treats it as unstored and leaves reads unscoped). + * * ## The two arms * * 1. The object carries `owner_id` (`ownership: 'user'`, the default) as the - * platform's unprovisioned anchor, and `external.columnMap` maps no remote - * column to it ⇒ record sharing refuses. + * platform's anchor, not declared ⇒ record sharing refuses. * 2. The object carries no `owner_id` (another `ownership` tier) and its - * `created_by` is the unprovisioned anchor that `external.columnMap` does - * not map, or is absent (`systemFields.audit: false`, where the floor - * compiles to a deny) ⇒ the floor refuses. + * `created_by` is the platform's anchor, not declared, or is absent + * (`systemFields.audit: false`, where the floor compiles to a deny) ⇒ the + * floor refuses. * - * A column the author DECLARES in `fields` is the author's (they vouch for a - * remote column of that name, or map one to it) and silences its arm — the - * same provenance the runtime guards read (`resolveInjectedColumnProvenance`, - * `@objectstack/spec/data`). + * Each arm words its finding for a `columnMap` entry that targets the column + * without a declaration: on a built-in SQL driver that map serves the check, + * on a plugin driver it does not, and the declaration is what holds on both. * * ## Deliberately not judged * @@ -90,7 +99,7 @@ const PUBLIC_READ_WRITE_WAY_OUT = "declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook"; /** Does `external.columnMap` (remote column → local field) map some remote column to `field`? */ -function columnMapServes(external: AnyRec, field: string): boolean { +function columnMapTargets(external: AnyRec, field: string): boolean { const map = external.columnMap; if (!map || typeof map !== 'object' || Array.isArray(map)) return false; return Object.values(map as AnyRec).includes(field); @@ -98,31 +107,23 @@ function columnMapServes(external: AnyRec, field: string): boolean { /** * The column whose missing storage refuses every member write on this object, - * and whether the injection supplies it at all — or `undefined` when a remote - * column serves the check that keys this object's writes. + * and whether the injection supplies it at all — or `undefined` when the + * author declares the column the check that keys this object's writes reads. */ -function unservedOwnershipColumn( - obj: AnyRec, - external: AnyRec, -): { column: string; injected: boolean } | undefined { +function undeclaredOwnershipColumn(obj: AnyRec): { column: string; injected: boolean } | undefined { const owner = resolveInjectedColumnProvenance(obj, OWNER_COLUMN); if (owner === 'author' || owner === 'injected-provisioned') return undefined; - if (owner === 'injected-unprovisioned') { - return columnMapServes(external, OWNER_COLUMN) ? undefined : { column: OWNER_COLUMN, injected: true }; - } + if (owner === 'injected-unprovisioned') return { column: OWNER_COLUMN, injected: true }; // No `owner_id` at all: record sharing abstains and the floor decides. const creator = resolveInjectedColumnProvenance(obj, CREATOR_COLUMN); if (creator === 'author' || creator === 'injected-provisioned') return undefined; - if (creator === 'injected-unprovisioned') { - return columnMapServes(external, CREATOR_COLUMN) ? undefined : { column: CREATOR_COLUMN, injected: true }; - } - return { column: CREATOR_COLUMN, injected: false }; + return { column: CREATOR_COLUMN, injected: creator === 'injected-unprovisioned' }; } /** - * Flag every writable external object on an owner-keyed sharing model whose - * ownership column no remote column serves. Returns the findings (empty = - * clean); the CLI folds them in as advisory warnings. + * Flag every writable external object on an owner-keyed sharing model that + * declares no ownership column a remote row serves. Returns the findings + * (empty = clean); the CLI folds them in as advisory warnings. */ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] { const out: ExternalOwnerSharingFinding[] = []; @@ -133,23 +134,23 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin const external = obj.external; if (!external || typeof external !== 'object' || Array.isArray(external)) return; if ((external as AnyRec).writable !== true) return; - const model = effectiveSharingModelOf(obj); - if (model === 'public') return; - const unserved = unservedOwnershipColumn(obj, external as AnyRec); - if (!unserved) return; + if (effectiveSharingModelOf(obj) === 'public') return; + const undeclared = undeclaredOwnershipColumn(obj); + if (!undeclared) return; + const { column } = undeclared; const name = typeof obj.name === 'string' ? obj.name : `(object ${i})`; - const declared = typeof obj.sharingModel === 'string' ? `'${obj.sharingModel}'` : "'private' (the default)"; + const model = typeof obj.sharingModel === 'string' ? `'${obj.sharingModel}'` : "'private' (the default)"; const check = - unserved.column === OWNER_COLUMN - ? 'record sharing reads it' + column === OWNER_COLUMN + ? 'record sharing reads owner_id' : 'with no owner_id, the ownership floor reads created_by == the caller'; - const mapWayOut = - unserved.column === OWNER_COLUMN - ? 'map the remote owner column to owner_id through external.columnMap and declare owner_id in fields' - : unserved.injected - ? 'map the remote creator column to created_by through external.columnMap' - : 'let the platform inject the audit columns (systemFields) and map the remote creator column to created_by through external.columnMap'; + const mapped = columnMapTargets(external as AnyRec, column); + const role = column === OWNER_COLUMN ? 'owner' : 'creator'; + const declareWayOut = + (undeclared.injected ? '' : 'let the platform inject the audit columns, ') + + `declare ${column} in fields and have the driver return the remote ${role} under that name ` + + '(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)'; out.push({ severity: 'warning', @@ -159,10 +160,16 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin // [#22161] One verdict sentence and one fix; the two checks, why // `ownership: 'none'` is no way out and what deletes meet are // `os explain security-external-owner-unmapped`. - message: - `external object on sharing model ${declared}, but no remote column serves '${unserved.column}' ` + - `(${check} before every edit and delete), so every member write is refused (403)`, - hint: `Either ${PUBLIC_READ_WRITE_WAY_OUT}, or ${mapWayOut}`, + message: mapped + ? `external object on sharing model ${model} maps a remote column to '${column}' through ` + + `external.columnMap but declares no '${column}' field; ${check} before every edit and delete, ` + + 'and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused (403)' + : `external object on sharing model ${model} declares no '${column}' field a remote row serves; ` + + `${check} before every edit and delete, so every member write is refused (403)`, + hint: mapped + ? `Declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself), ` + + `or ${PUBLIC_READ_WRITE_WAY_OUT}` + : `Either ${PUBLIC_READ_WRITE_WAY_OUT}, or ${declareWayOut}`, }); }); From 120864f47554b20338ec27a4c5e4a6249ebe8ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:18:49 +0000 Subject: [PATCH 4/4] fix(lint): the external-owner warning leaves room for an authored policy Review nits: the verdict now says a member write is refused unless an app-authored row-level policy admits the row (sharing's admitsByIdWrite retracts its refusal for such a row); the created_by hint drops the audit-columns clause (a declared created_by suffices with audit off); a datasource the stack declares with external.allowWrites: false is skipped. The fix line is pinned whole in the test. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- .../27332-external-owner-unmapped-warning.md | 4 +- packages/lint/src/rule-explanations.ts | 6 ++- .../validate-external-owner-sharing.test.ts | 50 +++++++++++++++++++ .../src/validate-external-owner-sharing.ts | 31 +++++++++--- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/.changeset/27332-external-owner-unmapped-warning.md b/.changeset/27332-external-owner-unmapped-warning.md index 77e684a0d06..23e1091dce6 100644 --- a/.changeset/27332-external-owner-unmapped-warning.md +++ b/.changeset/27332-external-owner-unmapped-warning.md @@ -7,10 +7,10 @@ feat(lint): `security-external-owner-unmapped` warns when a writable external ob Clause-②: no (a warning narrows nothing; no accepted metadata shape moves) Metadata-protocol: none -An external object (ADR-0015) on an owner-keyed sharing model (`private`, `public_read`, or none on a custom object) refuses every member edit and delete with 403 `PERMISSION_DENIED`, even for a member whose permission set grants `allowEdit`. Before the write, record sharing reads the row's `owner_id` and the platform ownership floor reads `created_by` equal to the caller, and no remote row carries either column. `os validate`, `os build` and `os lint` now say so with a warning on the object. +An external object (ADR-0015) on an owner-keyed sharing model (`private`, `public_read`, or none on a custom object) refuses every member edit and delete with 403 `PERMISSION_DENIED`, even for a member whose permission set grants `allowEdit`, unless an app-authored row-level policy admits the row. Before the write, record sharing reads the row's `owner_id` and the platform ownership floor reads `created_by` equal to the caller, and no remote row carries either column. `os validate`, `os build` and `os lint` now say so with a warning on the object. - The warning names two ways out. One: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook. Two: declare `owner_id` in `fields` and have the driver return the remote owner under that name. A built-in SQL driver does that through `external.columnMap`; `columnMap` is not applied by the engine, so a plugin driver must return the field itself. - A `columnMap` entry for `owner_id` without the declared field still warns, worded for it: it works on a built-in SQL driver only, and leaves reads unscoped by owner there. - `ownership: 'none'` (or `'org'` / `'business_unit'`) is not a way out. It drops `owner_id`, and the floor's `created_by` check refuses the same write, so the warning still fires and names `created_by`. Declaring `created_by` (served by the driver) silences it. -- It skips a read-only binding (`external.writable` not `true`), `public_read_write`, `controlled_by_parent`, and an object that declares the column its check reads. +- It skips a read-only binding (`external.writable` not `true`, or a datasource the stack declares with `external.allowWrites: false`), `public_read_write`, `controlled_by_parent`, and an object that declares the column its check reads. - It is a warning, never an error: `defineStack` and every publish door accept the same stacks as before. `os explain security-external-owner-unmapped` explains the two checks, and why `public_read_write` opens edits but not deletes. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a56a90e87b7..7b45b3727ae 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -743,7 +743,8 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = { 'the remote database owns the table and the platform creates no column for either. So the remote row ' + 'answers no `owner_id` (record sharing refuses) and no `created_by` equal to the caller (the floor ' + 'refuses). A member whose permission set grants `allowEdit` is refused every edit and every delete ' + - 'with 403 `PERMISSION_DENIED`; only an administrator can write. An object with no `sharingModel` is ' + + 'with 403 `PERMISSION_DENIED`, unless an app-authored row-level policy admits the row; otherwise only ' + + 'an administrator can write. An object with no `sharingModel` is ' + 'judged as \'private\', the value the runtime falls back to.', '`ownership: \'none\'` (or `\'org\'`, `\'business_unit\'`) is not a way out: without `owner_id` record ' + 'sharing stops judging the row, and the floor\'s `created_by` check refuses the same write. Turning ' + @@ -764,7 +765,8 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = { 'Deletes are not opened by `public_read_write`: that model opens edits, and the floor keeps deletes ' + 'to the row\'s `created_by`. On an external object that column has no storage either, so member ' + 'deletes stay refused unless the driver returns `created_by` or an authored policy or share grants them.', - 'The rule judges only a writable binding (`external.writable: true`) on a model that keys writes to ' + + 'The rule judges only a writable binding (`external.writable: true`, on a datasource this stack does ' + + 'not declare with `external.allowWrites: false`) on a model that keys writes to ' + 'an owner (`private`, `public_read`, or none on a custom object). `controlled_by_parent` is not judged: ' + 'the master record\'s gate decides its writes. It is a warning, not an error: the stack is valid and ' + 'an administrator can still write.', diff --git a/packages/lint/src/validate-external-owner-sharing.test.ts b/packages/lint/src/validate-external-owner-sharing.test.ts index 8d57688da13..160ad459b14 100644 --- a/packages/lint/src/validate-external-owner-sharing.test.ts +++ b/packages/lint/src/validate-external-owner-sharing.test.ts @@ -107,6 +107,15 @@ describe('security-external-owner-unmapped — which objects it judges', () => { expect(findingsFor({ ...ext({ sharingModel: 'private' }), external: { remoteName: 'rows' } })).toEqual([]); }); + it('stays silent on a datasource this stack declares with allowWrites: false, and warns when it allows writes', () => { + const datasource = (allowWrites: boolean) => ({ name: 'remote', schemaMode: 'external', external: { allowWrites } }); + const obj = ext({ sharingModel: 'private' }); + expect(validateExternalOwnerSharing({ datasources: [datasource(false)], objects: [obj] })).toEqual([]); + expect( + validateExternalOwnerSharing({ datasources: [datasource(true)], objects: [obj] }).map((f) => f.rule), + ).toEqual([SECURITY_EXTERNAL_OWNER_UNMAPPED]); + }); + it('stays silent on an object the platform stores, and on a system object with no sharingModel', () => { const { external: _external, ...local } = ext({ sharingModel: 'private' }); expect(findingsFor(local)).toEqual([]); @@ -161,3 +170,44 @@ describe("security-external-owner-unmapped — ownership: 'none' moves the refus expect(findingsFor(ext({ sharingModel: 'public_read_write', ownership: 'none' }))).toEqual([]); }); }); + +describe('security-external-owner-unmapped — the fix line', () => { + // The hint is the author's whole remedy: pinned whole, so a way out cannot be + // dropped or an unmeasured one added without this test saying so. + const hintOf = (obj: unknown) => findingsFor(obj).map((f) => f.hint); + + it('names both ways out for an undeclared owner_id', () => { + expect(hintOf(ext({ sharingModel: 'private' }))).toEqual([ + "Either declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook, or " + + 'declare owner_id in fields and have the driver return the remote owner under that name (a built-in ' + + 'SQL driver does it through external.columnMap; a plugin driver must return it itself)', + ]); + }); + + it('asks for the declaration when columnMap alone targets owner_id', () => { + expect(hintOf(ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } }))).toEqual([ + 'Declare owner_id in fields (a plugin driver must return the remote owner under that name itself), ' + + "or declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook", + ]); + }); + + it('names created_by on an object without owner_id, the audit columns on or off', () => { + const expected = + "Either declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook, or " + + 'declare created_by in fields and have the driver return the remote creator under that name (a built-in ' + + 'SQL driver does it through external.columnMap; a plugin driver must return it itself)'; + expect(hintOf(ext({ sharingModel: 'private', ownership: 'none' }))).toEqual([expected]); + expect(hintOf(ext({ sharingModel: 'private', ownership: 'none', systemFields: { audit: false } }))).toEqual([expected]); + }); + + it('a declared created_by with the audit columns off is enough', () => { + const fields = { ...NAME_FIELD, created_by: { type: 'lookup', reference: 'sys_user', label: 'Created by' } }; + expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none', systemFields: { audit: false }, fields }))).toEqual([]); + }); + + it('the verdict leaves room for an app-authored row-level policy', () => { + for (const f of findingsFor(ext({ sharingModel: 'private' }), ext({ sharingModel: 'private', ownership: 'none' }))) { + expect(f.message).toContain('unless an app-authored row-level policy admits the row'); + } + }); +}); diff --git a/packages/lint/src/validate-external-owner-sharing.ts b/packages/lint/src/validate-external-owner-sharing.ts index 1cbc9bdebd9..cb286d1fed3 100644 --- a/packages/lint/src/validate-external-owner-sharing.ts +++ b/packages/lint/src/validate-external-owner-sharing.ts @@ -23,8 +23,10 @@ * too, but provisions no storage for them, so a remote row carries neither: * `owner_id` reads as null (sharing answers `deny`) and `created_by` never * equals the caller (the floor refuses). A member whose permission set grants - * `allowEdit` gets 403 `PERMISSION_DENIED` on every row, and only an - * administrator can write. `ownership: 'none'` (or `'org'` / + * `allowEdit` gets 403 `PERMISSION_DENIED` on every row unless an + * app-authored row-level write policy admits it (sharing's + * `admitsByIdWrite` retracts its refusal for such a row), and otherwise only + * an administrator can write. `ownership: 'none'` (or `'org'` / * `'business_unit'`) is NOT a way out: it withholds `owner_id`, so record * sharing abstains, and the floor's `created_by` read refuses the same write * (measured: 403 on update and delete). @@ -54,8 +56,10 @@ * * ## Deliberately not judged * - * - A read-only binding (`external.writable` not `true`): no member can edit - * it whatever the sharing model says, so the refusal is not this one. + * - A read-only binding (`external.writable` not `true`), or one on a + * datasource this stack declares with `external.allowWrites: false`: no + * member can edit it whatever the sharing model says, so the refusal is + * not this one. * - `public_read_write` and `controlled_by_parent`: the first lifts the floor * for updates (who may edit is a hook's decision), the second hands row * writes to the master's gate. Deletes on a `public_read_write` external @@ -129,11 +133,23 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin const out: ExternalOwnerSharingFinding[] = []; if (!stack || typeof stack !== 'object') return out; const objects = recordsOf(stack.objects); + // ADR-0015's datasource half of the double write opt-in: a datasource this + // stack declares with `external.allowWrites: false` (also the parsed + // default) lets no write through, so no ownership check is what refuses. + const readOnlyDatasources = new Set( + recordsOf(stack.datasources) + .filter((ds) => { + const ext = ds.external; + return !!ext && typeof ext === 'object' && (ext as AnyRec).allowWrites === false; + }) + .map((ds) => ds.name), + ); objects.forEach((obj, i) => { const external = obj.external; if (!external || typeof external !== 'object' || Array.isArray(external)) return; if ((external as AnyRec).writable !== true) return; + if (readOnlyDatasources.has(obj.datasource)) return; if (effectiveSharingModelOf(obj) === 'public') return; const undeclared = undeclaredOwnershipColumn(obj); if (!undeclared) return; @@ -148,7 +164,6 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin const mapped = columnMapTargets(external as AnyRec, column); const role = column === OWNER_COLUMN ? 'owner' : 'creator'; const declareWayOut = - (undeclared.injected ? '' : 'let the platform inject the audit columns, ') + `declare ${column} in fields and have the driver return the remote ${role} under that name ` + '(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)'; @@ -163,9 +178,11 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin message: mapped ? `external object on sharing model ${model} maps a remote column to '${column}' through ` + `external.columnMap but declares no '${column}' field; ${check} before every edit and delete, ` + - 'and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused (403)' + 'and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused (403) ' + + 'unless an app-authored row-level policy admits the row' : `external object on sharing model ${model} declares no '${column}' field a remote row serves; ` + - `${check} before every edit and delete, so every member write is refused (403)`, + `${check} before every edit and delete, so every member write is refused (403) unless an app-authored ` + + 'row-level policy admits the row', hint: mapped ? `Declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself), ` + `or ${PUBLIC_READ_WRITE_WAY_OUT}`