Skip to content

Commit 1346052

Browse files
fix(lint): security-owd-unset's fix line on a writable external object names security-external-owner-unmapped's ways out, not 'private' (#27639)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiVy7F2atKWpo5rp3YPrqddh)_ Fixes #27472 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 可写外部对象(`external.writable: true`)没写 `sharingModel` 时,`security-owd-unset` 的修复提示不再推荐 `private`,改为给出 `security-external-owner-unmapped` 的两条出路;`os explain security-owd-unset` 补了一段说明这个例外。 - **为什么改:** 旧提示对所有自定义对象一律推荐 `private`,而可写外部对象照做后,另一条警告又说成员每次编辑都会被拒,两条提示互相打架。 - **风险与回滚:** 只改提示文字,报错级别和报错正文不变,元数据契约不变;普通对象的提示一字未动。回滚即 revert 本 PR。 - **先看:** `packages/lint/src/validate-security-posture.ts`(`SECURITY_OWD_UNSET` 分支的 hint)、`packages/lint/src/validate-external-owner-sharing.ts`(导出复用的两条出路措辞与判定)、`packages/lint/src/validate-external-owner-sharing.test.ts`(末尾新 describe)。 - **两分钟验证:** `pnpm --filter @objectstack/lint exec vitest run src/validate-external-owner-sharing.test.ts`,预期全绿,其中 `security-owd-unset's fix line on a writable external object` 五条通过。 - **需要你做的:** 无,席位落地。 ## What changes - **`security-owd-unset` fix line (`validate-security-posture.ts`).** For a custom object with no `sharingModel` that `security-external-owner-unmapped` would judge, the hint now names that rule's two ways out instead of recommending `'private'`. Those objects are a writable binding (`external.writable: true`), not on a datasource this stack declares with `external.allowWrites: false`, and declaring no ownership column a remote row serves (`owner_id`, or `created_by` on an object without one). The new hint reads, for `spike_owned`: `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) and declare sharingModel: 'private' or 'public_read'; 'controlled_by_parent' for master-detail children` Severity (`error`), the verdict line and `path` are unchanged. Every other object keeps today's hint byte for byte. - **One phrasing, not three (`validate-external-owner-sharing.ts`).** `PUBLIC_READ_WRITE_WAY_OUT` and the declaration clause (`declareOwnershipColumnWayOut(column)`) are now exported from the rule that owns them. The judgement "does this object have an ownership gap" moves into `externalOwnershipGap(obj, readOnlyDatasources)`, and the datasource set into `readOnlyExternalDatasources(stack)`. Both rules call the same predicate, so the hint swaps on exactly the objects the warning fires on. These are module exports, not barrel exports: the package's public surface is unchanged. The external rule's own findings are byte-identical, and its existing whole-hint pins stay green. - **`os explain security-owd-unset` (`rule-explanations.ts`).** A new paragraph explains the writable-external exception to the `'private'` recommendation, names the two ways out and points at `os explain security-external-owner-unmapped`. - Changeset: `@objectstack/lint` patch. ## Tests - `validate-external-owner-sharing.test.ts` adds the describe block `security-owd-unset's fix line on a writable external object`: - `spike_owned` (the spike stack, verbatim) gets the external hint (both shared clauses, no `recommended`), with severity, path and message unchanged; - a managed custom object (`leave_request`) keeps the old hint, pinned whole; - an `ownership: 'none'` external object is told to declare `created_by`; - a read-only binding, a declared `owner_id` and an `allowWrites: false` datasource keep the old hint; - either way out, once taken (`public_read_write`, or `private` plus a declared `owner_id`), clears both rules. - `validate-security-posture.test.ts`: the explanation pin also asserts the external paragraph. The receiver meta-test (#5017) excuses `ownershipGap`, which is the helper's own return value and not metadata. - Ablation: with the fix committed, `scripts/ablation-replace.mjs` replaced `const ownershipGap = externalOwnershipGap(obj, readOnlyDatasources);` with `undefined` (anchor 1 → 0, blob `ce9dd0f8e857` → `f178c04309b6`). Two of the new pins went red (`spike_owned …`, `names created_by …`), 24 stayed green. The tool restored the file (blob == HEAD, `git diff HEAD` empty). The test imports `src/` by relative path, so no `dist/` rebuild was involved. ## Acceptance notes - **The hint swaps only where the warning fires, which is narrower than the card's literal "`external.writable: true`".** An external object that already declares `owner_id` (or `created_by` on an object without `owner_id`), or that sits on an `allowWrites: false` datasource, keeps the `'private'` recommendation. There `security-external-owner-unmapped` is silent and `'private'` is not the refused shape: the owner edits, and since #27476 reads are scoped by the owner it proves. This follows the card's first clause ("names the ways out `security-external-owner-unmapped` names"). Where that rule names none, there is nothing to name. - The guide (`content/docs/data-modeling/virtual-objects.mdx`, the "Choose the sharing model on purpose" callout) still reads true and is not edited. - `packages/cli/test/explain-rule-id.test.ts` pins the managed-object hint, which is unchanged. It is declared to CI: the CLI's dependency closure is not built in this container (the file fails to load on `@objectstack/types` resolution, before any assertion runs). ## Verification <details><summary>Local runs (head 4e9b79c)</summary> The tests ran on the code tree of `c4b4bd65`. The head `4e9b79ca` adds only the changeset, so its code is the same. The gate commands ran at `4e9b79ca`. - `pnpm --filter '@objectstack/lint^...' build` under `os-verify-lock.sh`: VERDICT command-exit 0. - `pnpm --filter @objectstack/lint typecheck && pnpm --filter @objectstack/lint test` under the lock: VERDICT command-exit 0; `Test Files 177 passed (177)`, `Tests 7604 passed | 5 skipped (7609)`. The typecheck ran `tsc --noEmit` and `check:test-typecheck`. - Targeted: `vitest run src/validate-external-owner-sharing.test.ts src/validate-security-posture.test.ts src/rule-explanations.test.ts src/validate-security-posture.runtime-surface.test.ts`: `Test Files 4 passed (4)`, `Tests 228 passed (228)`. - Ablation (`scripts/ablation-replace.mjs`, see Tests): VERDICT command-exit 1, `Tests 2 failed | 24 passed (26)`, restored `blob == HEAD (ce9dd0f)`, `git diff HEAD` empty. - `node scripts/pm/dispatch-gates.mjs --commands`: 58 commands derived; all 58 were run with their exit codes recorded. `--ran`: "58 derived famil(ies) accounted for — 55 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)". 55 exited 0, including `check:docs-transcript-drift` ("4 declared transcript value(s) across 471 page(s) … equal what the registry derives today"), `check:doc-authoring`, `check:nul-bytes`, `check:cross-package-test-inputs` and `check:type-check-debt`. - NOT MEASURED, exit 3 (prerequisite), left to CI: - `node scripts/check-plugin-teardown-shape.mjs --self-test`: a shallow clone cannot reach its pinned positive-control commit; - `pnpm check:dual-build-cjs-loads` and `pnpm check:lean-entry-closure`: both read built `dist/` of packages outside this closure. - NOT MEASURED, declared to CI: `packages/cli/test/explain-rule-id.test.ts`. The CLI closure is not built here, so the file fails to load (`@objectstack/types` entry unresolved) before any assertion runs. It pins the managed-object hint, which this diff leaves unchanged. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4278c3b commit 1346052

6 files changed

Lines changed: 240 additions & 28 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): `security-owd-unset`'s fix line on a writable external object names the ways out `security-external-owner-unmapped` names, instead of recommending `'private'`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`security-owd-unset` refuses a custom object with no `sharingModel`, and its fix line recommended `sharingModel: 'private'` for every such object. On a writable external object (`external.writable: true`) whose ownership column no remote row serves, `'private'` is the shape `security-external-owner-unmapped` warns refuses every member edit: the author followed one rule's advice into the other's warning.
11+
12+
- **Now, on a writable binding.** For exactly the objects `security-external-owner-unmapped` judges (`external.writable: true`, with no declared `owner_id`, or `created_by` on an object without `owner_id`), the fix line reads: declare `sharingModel: 'public_read_write'` and decide who may edit in a `beforeUpdate` hook, or declare the ownership column in `fields` and have the driver return the remote owner under that name, and declare `'private'` or `'public_read'`; `'controlled_by_parent'` for master-detail children. Both clauses are that rule's own wording, including its shorter one when an `external.columnMap` entry already targets the column.
13+
- **Now, on a read-only binding.** An external object that no member writes (`external.writable` not `true`, or a datasource this stack declares with `external.allowWrites: false`) and whose `owner_id` no declared field or `external.columnMap` entry names is told to declare `sharingModel: 'public_read'`: on `'private'` a member reads none of its rows.
14+
- **Unchanged.** The severity (`error`) and the verdict line. Every other custom object, and an external object whose owner is provable, keeps the `'private'` recommendation.
15+
- `os explain security-owd-unset` explains both external cases and points at `os explain security-external-owner-unmapped`.
16+
17+
The same fix line reaches the runtime publish gate's 422 issue for an `object` write. That gate sees no `datasources`, so a writable binding on an `allowWrites: false` datasource gets the writable-binding line there; neither door recommends `'private'` for it.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ const SECURITY_OWD_UNSET_EXPLANATION: RuleExplanation = {
114114
'default); \'public_read\' — everyone reads, the owner writes; \'public_read_write\' — everyone ' +
115115
'reads and writes; \'controlled_by_parent\' — access derives from the master record (a ' +
116116
'master-detail child).',
117+
'An external object with no provable owner is the exception to that recommendation. On a writable ' +
118+
'binding (`external.writable: true`) the remote row carries no `owner_id` or `created_by` the platform can read, so on \'private\' every ' +
119+
'member edit is refused, and where the object carries `owner_id` a member reads no row at all ' +
120+
'(`os explain security-external-owner-unmapped`). There the fix line names that rule\'s two ways out ' +
121+
'instead: \'public_read_write\' with a `beforeUpdate` hook that decides who may edit, or the ownership ' +
122+
'column (`owner_id`, or `created_by` on an object without one) declared in `fields` and returned by ' +
123+
'the driver, after which \'private\' or \'public_read\' admits edits by the user that column names. ' +
124+
'A read-only external binding (`external.writable` not `true`, or a datasource declared with ' +
125+
'`external.allowWrites: false`) whose `owner_id` no declared field or `external.columnMap` entry names ' +
126+
'is told to declare \'public_read\' instead: no member writes it, and on \'private\' a member reads none ' +
127+
'of its rows. An external object whose owner is provable (a declared field, or on a read-only binding ' +
128+
'a `columnMap` entry onto `owner_id`) keeps the usual fix line.',
117129
'System objects (`isSystem: true`, or a name starting with `sys_`) are not judged: the platform ' +
118130
'owns their posture.',
119131
],

‎packages/lint/src/validate-external-owner-sharing.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import { describe, expect, it } from 'vitest';
44
import { defineStack } from '@objectstack/spec';
55
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
66
import {
7+
PUBLIC_READ_WRITE_WAY_OUT,
8+
READ_ONLY_EXTERNAL_WAY_OUT,
79
SECURITY_EXTERNAL_OWNER_UNMAPPED,
10+
declareOwnershipColumnWayOut,
811
validateExternalOwnerSharing,
912
} from './validate-external-owner-sharing.js';
13+
import { SECURITY_OWD_UNSET, validateSecurityPosture } from './validate-security-posture.js';
1014

1115
/**
1216
* The two external objects of `packages/runtime/src/plugin-driver-external-object.spike.test.ts`,
@@ -220,3 +224,88 @@ describe('security-external-owner-unmapped — the fix line', () => {
220224
}
221225
});
222226
});
227+
228+
// [#27472] `security-owd-unset` refuses a custom object with no sharingModel, and its fix line used
229+
// to recommend 'private' everywhere: on a writable external object that is the shape this rule warns
230+
// refuses every member edit. Its fix line for the objects this rule judges is built from this
231+
// rule's own two ways out; every other object keeps the 'private' recommendation.
232+
describe("security-owd-unset's fix line on an external object", () => {
233+
const owdUnsetHint = (stack: Record<string, unknown>, where: string) => {
234+
const findings = validateSecurityPosture(stack).filter((f) => f.rule === SECURITY_OWD_UNSET && f.where === where);
235+
expect(findings, where).toHaveLength(1);
236+
return findings[0]!.hint ?? '';
237+
};
238+
const OLD_HINT =
239+
"declare sharingModel: 'private' (owner + shares; recommended), 'public_read', " +
240+
"'public_read_write', or 'controlled_by_parent' (master-detail children)";
241+
242+
it("spike_owned gets this rule's ways out and no 'private' recommendation", () => {
243+
const hint = owdUnsetHint(SPIKE_STACK, 'object "spike_owned"');
244+
expect(hint).toContain(PUBLIC_READ_WRITE_WAY_OUT);
245+
expect(hint).toContain(declareOwnershipColumnWayOut('owner_id'));
246+
expect(hint).not.toContain('recommended');
247+
expect(hint).not.toContain('\n');
248+
// Severity and verdict are the rule's own and do not move.
249+
const [finding] = validateSecurityPosture(SPIKE_STACK).filter((f) => f.where === 'object "spike_owned"');
250+
expect(finding).toMatchObject({ severity: 'error', path: 'objects[1].sharingModel' });
251+
expect(finding!.message).toBe(
252+
"custom object declares no sharingModel (OWD); the runtime falls back to 'private', " +
253+
'but the baseline must be an authored decision',
254+
);
255+
});
256+
257+
it('a managed custom object keeps the private recommendation', () => {
258+
expect(owdUnsetHint({ objects: [{ name: 'leave_request', label: 'Leave Request' }] }, 'object "leave_request"')).toBe(
259+
OLD_HINT,
260+
);
261+
});
262+
263+
it('names created_by on an external object without owner_id', () => {
264+
const hint = owdUnsetHint({ objects: [ext({ ownership: 'none' })] }, 'object "remote_row"');
265+
expect(hint).toContain(declareOwnershipColumnWayOut('created_by'));
266+
expect(hint).not.toContain('recommended');
267+
});
268+
269+
it('words the mapped case as this rule does: only the declaration is missing', () => {
270+
const hint = owdUnsetHint({ objects: [ext({}, { columnMap: { assignee: 'owner_id' } })] }, 'object "remote_row"');
271+
expect(hint).toContain(declareOwnershipColumnWayOut('owner_id', true));
272+
expect(hint).toContain(PUBLIC_READ_WRITE_WAY_OUT);
273+
expect(hint).not.toContain('recommended');
274+
});
275+
276+
// [#27425] On 'private', an external object whose owner_id is the platform's unprovable anchor
277+
// reads no row for a member, whatever its writability: a read-only binding is told 'public_read'.
278+
it("recommends 'public_read' on a read-only binding with an unprovable owner", () => {
279+
const readOnlyDatasource = { name: 'remote', schemaMode: 'external', external: { allowWrites: false } };
280+
const { writable: _writable, ...noWritable } = ext().external;
281+
for (const [label, stack] of [
282+
['writable: false', { objects: [ext({}, { writable: false })] }],
283+
['writable unset', { objects: [{ ...ext(), external: noWritable }] }],
284+
['allowWrites: false', { datasources: [readOnlyDatasource], objects: [ext()] }],
285+
] as const) {
286+
const hint = owdUnsetHint(stack, 'object "remote_row"');
287+
expect(hint, label).toBe(READ_ONLY_EXTERNAL_WAY_OUT);
288+
expect(hint, label).not.toContain('recommended');
289+
}
290+
});
291+
292+
it('keeps the private recommendation where the owner is provable', () => {
293+
const ownerField = { ...NAME_FIELD, owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' } };
294+
for (const [label, stack] of [
295+
['declared owner_id, writable', { objects: [ext({ fields: ownerField })] }],
296+
['declared owner_id, read-only', { objects: [ext({ fields: ownerField }, { writable: false })] }],
297+
['columnMap onto owner_id, read-only', { objects: [ext({}, { writable: false, columnMap: { assignee: 'owner_id' } })] }],
298+
] as const) {
299+
expect(owdUnsetHint(stack, 'object "remote_row"'), label).toBe(OLD_HINT);
300+
}
301+
});
302+
303+
it('either way out it names, once taken, clears this rule too', () => {
304+
const ownerField = { ...NAME_FIELD, owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' } };
305+
for (const obj of [ext({ sharingModel: 'public_read_write' }), ext({ sharingModel: 'private', fields: ownerField })]) {
306+
const stack = { objects: [obj] };
307+
expect(validateSecurityPosture(stack).map((f) => f.rule)).toEqual([]);
308+
expect(validateExternalOwnerSharing(stack)).toEqual([]);
309+
}
310+
});
311+
});

‎packages/lint/src/validate-external-owner-sharing.ts‎

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@
3737
* column to `owner_id` changes nothing, and the driver must return the field
3838
* itself. So the author's vouch this rule reads is a DECLARED field — the same
3939
* provenance the runtime guards read (`resolveInjectedColumnProvenance`,
40-
* `@objectstack/spec/data`) — never a `columnMap` entry alone. A declared
41-
* `owner_id` also scopes reads by owner; the platform's own injected one does
42-
* not (plugin-sharing treats it as unstored and leaves reads unscoped).
40+
* `@objectstack/spec/data`) — never a `columnMap` entry alone. Reads follow
41+
* the same anchor: a declared `owner_id` scopes them by owner, while the
42+
* platform's own injected one, with no `columnMap` entry naming it, proves no
43+
* owner, so on an owner-scoped model a member's read scope admits no row
44+
* (plugin-sharing `hasPhantomOwnerAnchor`, #27425) and an edit answers 404.
4345
*
4446
* ## The two arms
4547
*
@@ -99,9 +101,37 @@ type AnyRec = Record<string, unknown>;
99101
const OWNER_COLUMN = 'owner_id';
100102
const CREATOR_COLUMN = 'created_by';
101103

102-
const PUBLIC_READ_WRITE_WAY_OUT =
104+
/**
105+
* The two ways out this rule names, as fix-line clauses. `security-owd-unset`
106+
* words its fix line for the same objects from these, so an author following
107+
* either rule's advice lands on a shape the other does not refuse.
108+
*/
109+
export const PUBLIC_READ_WRITE_WAY_OUT =
103110
"declare sharingModel: 'public_read_write' and decide who may edit in a beforeUpdate hook";
104111

112+
/**
113+
* The declaration way out, for the column {@link externalOwnershipGap} names.
114+
* `mapped`: an `external.columnMap` entry already targets the column, so what
115+
* is missing is only the declared field.
116+
*/
117+
export function declareOwnershipColumnWayOut(column: string, mapped = false): string {
118+
const role = column === OWNER_COLUMN ? 'owner' : 'creator';
119+
if (mapped) return `declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself)`;
120+
return (
121+
`declare ${column} in fields and have the driver return the remote ${role} under that name ` +
122+
'(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)'
123+
);
124+
}
125+
126+
/**
127+
* The fix-line clause `security-owd-unset` gives a read-only external binding
128+
* whose owner anchor is unprovable ({@link hasUnprovableOwnerAnchor}): no
129+
* member writes it, and on 'private' a member reads none of its rows.
130+
*/
131+
export const READ_ONLY_EXTERNAL_WAY_OUT =
132+
"declare sharingModel: 'public_read' (no member writes a read-only binding, and on 'private' a member " +
133+
'reads no row: no declared field or external.columnMap entry names the remote owner_id)';
134+
105135
/** Does `external.columnMap` (remote column → local field) map some remote column to `field`? */
106136
function columnMapTargets(external: AnyRec, field: string): boolean {
107137
const map = external.columnMap;
@@ -129,50 +159,83 @@ function undeclaredOwnershipColumn(obj: AnyRec): { column: string; injected: boo
129159
* declares no ownership column a remote row serves. Returns the findings
130160
* (empty = clean); the CLI folds them in as advisory warnings.
131161
*/
132-
export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] {
133-
const out: ExternalOwnerSharingFinding[] = [];
134-
if (!stack || typeof stack !== 'object') return out;
135-
const objects = recordsOf(stack.objects);
136-
// ADR-0015's datasource half of the double write opt-in: a datasource this
137-
// stack declares with `external.allowWrites: false` (also the parsed
138-
// default) lets no write through, so no ownership check is what refuses.
139-
const readOnlyDatasources = new Set(
162+
/**
163+
* ADR-0015's datasource half of the double write opt-in: the datasources this
164+
* stack declares with `external.allowWrites: false` (also the parsed default)
165+
* let no write through, so no ownership check is what refuses.
166+
*/
167+
export function readOnlyExternalDatasources(stack: AnyRec): ReadonlySet<unknown> {
168+
return new Set(
140169
recordsOf(stack.datasources)
141170
.filter((ds) => {
142171
const ext = ds.external;
143172
return !!ext && typeof ext === 'object' && (ext as AnyRec).allowWrites === false;
144173
})
145174
.map((ds) => ds.name),
146175
);
176+
}
177+
178+
/**
179+
* The ownership column whose missing storage refuses every member write on
180+
* this object under an owner-keyed sharing model — or `undefined` when the
181+
* object is not a writable external binding, sits on a datasource that lets
182+
* no write through, or declares the column the check reads. `mapped`: an
183+
* `external.columnMap` entry targets the column, so only the declaration is
184+
* missing. The sharing model itself is the caller's to judge.
185+
*/
186+
export function externalOwnershipGap(
187+
obj: AnyRec,
188+
readOnlyDatasources: ReadonlySet<unknown>,
189+
): { column: string; injected: boolean; mapped: boolean } | undefined {
190+
const external = obj.external;
191+
if (!external || typeof external !== 'object' || Array.isArray(external)) return undefined;
192+
if ((external as AnyRec).writable !== true) return undefined;
193+
if (readOnlyDatasources.has(obj.datasource)) return undefined;
194+
const undeclared = undeclaredOwnershipColumn(obj);
195+
if (!undeclared) return undefined;
196+
return { ...undeclared, mapped: columnMapTargets(external as AnyRec, undeclared.column) };
197+
}
198+
199+
/**
200+
* [#27425] The lint's reading of plugin-sharing's `hasPhantomOwnerAnchor`: an
201+
* external object whose `owner_id` is the platform's injected anchor (no
202+
* declared field) and no `external.columnMap` entry targets it. No owner is
203+
* provable for such an object, so record sharing admits no row by ownership on
204+
* an owner-scoped model, whatever the binding's writability.
205+
*/
206+
export function hasUnprovableOwnerAnchor(obj: AnyRec): boolean {
207+
const external = obj.external;
208+
if (!external || typeof external !== 'object' || Array.isArray(external)) return false;
209+
if (columnMapTargets(external as AnyRec, OWNER_COLUMN)) return false;
210+
return resolveInjectedColumnProvenance(obj, OWNER_COLUMN) === 'injected-unprovisioned';
211+
}
212+
213+
export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] {
214+
const out: ExternalOwnerSharingFinding[] = [];
215+
if (!stack || typeof stack !== 'object') return out;
216+
const objects = recordsOf(stack.objects);
217+
const readOnlyDatasources = readOnlyExternalDatasources(stack);
147218

148219
objects.forEach((obj, i) => {
149-
const external = obj.external;
150-
if (!external || typeof external !== 'object' || Array.isArray(external)) return;
151-
if ((external as AnyRec).writable !== true) return;
152-
if (readOnlyDatasources.has(obj.datasource)) return;
153220
if (effectiveSharingModelOf(obj) === 'public') return;
154-
const undeclared = undeclaredOwnershipColumn(obj);
221+
const undeclared = externalOwnershipGap(obj, readOnlyDatasources);
155222
if (!undeclared) return;
156223

157-
const { column } = undeclared;
224+
const { column, mapped } = undeclared;
158225
const name = typeof obj.name === 'string' ? obj.name : `(object ${i})`;
159226
const model = typeof obj.sharingModel === 'string' ? `'${obj.sharingModel}'` : "'private' (the default)";
160227
const check =
161228
column === OWNER_COLUMN
162229
? 'record sharing reads owner_id'
163230
: 'with no owner_id, the ownership floor reads created_by == the caller';
164-
const mapped = columnMapTargets(external as AnyRec, column);
165-
const role = column === OWNER_COLUMN ? 'owner' : 'creator';
166231
// [#27425] On `private`, record sharing also scopes READS by owner_id, and
167232
// an owner it cannot read admits no row: the edit's pre-image read finds
168233
// none, so the refusal is a 404 rather than the write check's 403.
169234
const refusal =
170235
column === OWNER_COLUMN && effectiveSharingModelOf(obj) === 'private'
171236
? "(404: the member's read scope admits no row)"
172237
: '(403)';
173-
const declareWayOut =
174-
`declare ${column} in fields and have the driver return the remote ${role} under that name ` +
175-
'(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)';
238+
const declareWayOut = declareOwnershipColumnWayOut(column, mapped);
176239

177240
out.push({
178241
severity: 'warning',
@@ -191,8 +254,7 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin
191254
`${check} before every edit and delete, so every member write is refused ${refusal} unless an app-authored ` +
192255
'row-level policy admits the row',
193256
hint: mapped
194-
? `Declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself), ` +
195-
`or ${PUBLIC_READ_WRITE_WAY_OUT}`
257+
? `D${declareWayOut.slice(1)}, or ${PUBLIC_READ_WRITE_WAY_OUT}`
196258
: `Either ${PUBLIC_READ_WRITE_WAY_OUT}, or ${declareWayOut}`,
197259
});
198260
});

‎packages/lint/src/validate-security-posture.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
145145
const text = explanation!.paragraphs.join('\n');
146146
expect(text).toContain('ADR-0090 D1');
147147
expect(text).toContain("read and edit every other user's records");
148+
// [#27472] The writable-external exception to the 'private' recommendation, and the rule it defers to.
149+
expect(text).toContain('external.writable: true');
150+
expect(text).toContain('os explain security-external-owner-unmapped');
151+
expect(text).toContain("is told to declare 'public_read' instead");
148152
expect(finding.message).not.toContain('incident');
149153
});
150154

@@ -1792,6 +1796,8 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
17921796
'cbpDetails', // #26591: the resolving details by name — JS Map methods.
17931797
'policies', // one set's `rowLevelSecurity` records — `.length`, a JS property.
17941798
'predicate', // a policy's `using` / `check` text, a string — `.trim`.
1799+
// #27472: `externalOwnershipGap`'s answer — `.column`, a field of that helper's own return value.
1800+
'ownershipGap',
17951801
]);
17961802
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
17971803
});

0 commit comments

Comments
 (0)