diff --git a/.changeset/26591-cbp-owner-lint-owner-id-only.md b/.changeset/26591-cbp-owner-lint-owner-id-only.md new file mode 100644 index 00000000000..2799dd9bb64 --- /dev/null +++ b/.changeset/26591-cbp-owner-lint-owner-id-only.md @@ -0,0 +1,16 @@ +--- +'@objectstack/lint': patch +'@objectstack/spec': patch +--- + +fix(lint): `security-controlled-by-parent-owner-policy-without-opt-in` prescribes `ownerMayEdit` only for a policy keyed on `owner_id` (#26591) + +Clause-②: no (lint advice is not a contract face; no accepted metadata shape moves) +Metadata-protocol: none + +`sharing.controlledByParent.ownerMayEdit: true` admits one caller past a `controlled_by_parent` detail's master gate: the user in the stored row's `owner_id`, on a detail that carries that column. The lint arm used to prescribe the opt-in for any `update` / `all` policy of the form `FIELD == current_user.id`. So an app whose accountable person is its own user field (`owner == current_user.id`) declared the opt-in, lint went quiet, and that user was still refused with `403 requires edit access to its master record`. + +- The opt-in is still prescribed, as before, for a policy keyed on `owner_id` on a detail that carries it and has not opted in, and the arm stays silent once it has. +- A policy keyed only on another user field is now flagged whether or not the detail opted in. The verdict says the opt-in admits only the stored `owner_id`. The hint never names the opt-in: it says to key the accountable person on `owner_id`, or drop the policy. +- On a detail with no `owner_id` (`ownership: 'business_unit' | 'org' | 'none'` and no `owner_id` field of its own), the opt-in names nobody. A policy keyed on `owner_id` there gets the same treatment, and the hint says to give the detail an `owner_id` column. +- The runtime gate is unchanged. The rule's `os explain` entry covers both cases. The `ownerMayEdit` evidence line in `@objectstack/spec`'s liveness ledger now describes the narrowed arm. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 61a7653c826..8aa85af6b52 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -713,14 +713,20 @@ const SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION: RuleExplanation = { 'platform\'s injected per-record owner — updates that row by id with READ access to the master, ' + 'and the detail\'s own policies then decide as authored. Everyone else, and every insert, delete, ' + 'multi-row update and on-behalf-of write, still needs edit access to the master. The opt-in ' + - 'reads `owner_id` and nothing else: a policy keyed on another field stays a narrowing even ' + - 'after it, so key the policy on `owner_id` or declare what the other field means in prose.', + 'reads the stored `owner_id` and nothing else, and only on a detail that carries that column ' + + '(`ownership: \'user\'`, the default; never `\'business_unit\'`, `\'org\'` or `\'none\'`). So the ' + + 'rule prescribes the opt-in only for a policy that keys the caller on `owner_id` there. A policy ' + + 'keyed only on another user field — `owner == current_user.id`, where `owner` is the detail\'s own ' + + 'accountable-person field — stays a narrowing with or without the opt-in, and the rule says so in ' + + 'both states instead of prescribing an opt-in that would not admit that user.', 'This is a warning, not an error: a narrowing can be meant (only the owner among the master\'s ' + - 'editors may update), and no runtime door refuses the shape. To fix it, declare the opt-in on ' + - 'the detail when the owner is meant to write with read on the master, or drop the policy when ' + - 'the master gate alone is the intended rule. The opt-in is refused by the parse on an object ' + - 'whose `sharingModel` is not `controlled_by_parent`, so it cannot be declared where nothing ' + - 'reads it.', + 'editors may update), and no runtime door refuses the shape. To fix a policy keyed on `owner_id`, ' + + 'declare the opt-in on the detail when the owner is meant to write with read on the master, or ' + + 'drop the policy when the master gate alone is the intended rule. To fix a policy keyed on another ' + + 'user field, keep the accountable person in `owner_id` (the column the master gate compares the ' + + 'caller against) and key the policy on it, or drop the policy. The opt-in is refused by the parse ' + + 'on an object whose `sharingModel` is not `controlled_by_parent`, so it cannot be declared where ' + + 'nothing reads it.', ], }; diff --git a/packages/lint/src/validate-security-posture-owner-may-edit.test.ts b/packages/lint/src/validate-security-posture-owner-may-edit.test.ts index 157430fbb6f..2e25750d91b 100644 --- a/packages/lint/src/validate-security-posture-owner-may-edit.test.ts +++ b/packages/lint/src/validate-security-posture-owner-may-edit.test.ts @@ -167,6 +167,89 @@ describe('security-controlled-by-parent-owner-policy-without-opt-in (#25760)', ( }); }); + // [#26591] The opt-in admits ONE column — the stored row's `owner_id`, on a + // detail that carries it — so the arm prescribes it only there. hotclm's + // shape: the accountable person is the detail's own user field `owner`, and + // the requester's update policy keys on it. + describe('the opt-in is prescribed only where it admits (#26591)', () => { + const OWNER_FIELD = { owner: { name: 'owner', type: 'user', reference: 'sys_user', label: 'Owner' } }; + /** hotclm's `clm_obligation`: a `controlled_by_parent` detail with its own `owner` user field. */ + const hotclmDetail = (extra: Record = {}) => + detail({ fields: { ...DETAIL_FIELDS, ...OWNER_FIELD }, ...extra }); + const ownerPolicy = policy({ name: 'obligation_requester_own', using: 'owner == current_user.id' }); + + it('an owner_id-keyed policy without the opt-in: flagged, and the opt-in is the remedy', () => { + const findings = ownerArm(stack([MASTER, hotclmDetail()], policy())); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('Declare sharing: { controlledByParent: { ownerMayEdit: true } }'); + }); + + it('an owner_id-keyed policy with the opt-in: silent', () => { + expect(ownerArm(stack([MASTER, hotclmDetail(OPT_IN)], policy()))).toEqual([]); + }); + + it('an other-field policy without the opt-in: flagged with the owner_id remedy, never the opt-in', () => { + const findings = ownerArm(stack([MASTER, hotclmDetail()], ownerPolicy)); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, + path: 'permissions[0].rowLevelSecurity[0].using', + }); + expect(findings[0].message).toContain('(owner == current_user.id)'); + expect(findings[0].message).toContain("ownerMayEdit admits only the row's stored owner_id"); + expect(findings[0].hint).toContain('Key the accountable person on owner_id'); + expect(findings[0].hint).not.toContain('ownerMayEdit'); + }); + + it('an other-field policy with the opt-in: still flagged, the opt-in does not admit that field', () => { + const findings = ownerArm(stack([MASTER, hotclmDetail(OPT_IN)], ownerPolicy)); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('(owner == current_user.id)'); + expect(findings[0].message).toContain("the opt-in it declares (sharing.controlledByParent.ownerMayEdit) admits only the row's stored owner_id"); + expect(findings[0].hint).toContain('Key the accountable person on owner_id'); + expect(findings[0].hint).not.toContain('ownerMayEdit'); + }); + + it('a predicate keying owner_id beside another field is one the opt-in serves: prescribed without it, silent with it', () => { + const mixed = policy({ using: 'owner == current_user.id || owner_id == current_user.id' }); + const findings = ownerArm(stack([MASTER, hotclmDetail()], mixed)); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('ownerMayEdit: true'); + expect(ownerArm(stack([MASTER, hotclmDetail(OPT_IN)], mixed))).toEqual([]); + }); + + describe('a detail that carries no owner_id: the opt-in names nobody there', () => { + it.each([ + ['ownership: org', { ownership: 'org' }], + ['ownership: none', { ownership: 'none' }], + ['ownership: business_unit', { ownership: 'business_unit' }], + ])('%s — an owner_id-keyed policy is flagged with the owner-column remedy, with or without the opt-in', (_label, ownership) => { + for (const extra of [ownership, { ...ownership, ...OPT_IN }]) { + const findings = ownerArm(stack([MASTER, detail(extra)], policy())); + expect(findings, JSON.stringify(extra)).toHaveLength(1); + expect(findings[0].message).toContain('a column "omd_obligation" does not carry'); + expect(findings[0].hint).toContain('Give "omd_obligation" an owner_id column'); + expect(findings[0].hint).not.toContain('ownerMayEdit'); + } + }); + + it("an author's own owner_id field is the column the runtime reads: the opt-in is prescribed, then silent", () => { + const ownOwner = { ownership: 'none', fields: { ...DETAIL_FIELDS, owner_id: { name: 'owner_id', type: 'user', reference: 'sys_user', label: 'Owner' } } }; + expect(ownerArm(stack([MASTER, detail(ownOwner)], policy()))[0]?.hint).toContain('ownerMayEdit: true'); + expect(ownerArm(stack([MASTER, detail({ ...ownOwner, ...OPT_IN })], policy()))).toEqual([]); + }); + }); + + it('the hotclm fixture is key-legal: the stack schema refuses no key it carries', () => { + const result = ObjectStackSchema.safeParse({ + manifest: { id: 'com.example.omd', name: 'omd', version: '1.0.0', type: 'app' }, + ...stack([MASTER, hotclmDetail(OPT_IN)], ownerPolicy), + }); + expect(result.success ? [] : result.error.issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + }); + describe('the parsed path reads what the raw path reads', () => { it('the PARSED detail and set: flagged without the opt-in, silent with it', () => { const parsedSet = PermissionSetSchema.parse({ name: 'omd_requester', objects: {}, rowLevelSecurity: [policy()] }); diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 2720bdcf38d..eb573fa4809 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -1579,6 +1579,14 @@ const NOT_SCHEMA_RECEIVERS: Record = { // hint text. The scan reads code with its string literals intact, so the // spelling presents as a receiver `current_user` reading a key `id`. current_user: "the RLS predicate grammar's `current_user.id`, a string literal in the owner arm's message text — not a receiver.", + // [#26591] The owner arm keeps, per controlled_by_parent detail, whether it + // opted in and whether it carries `owner_id` — this file's own fold; the + // authored reads behind it are `controlledByParentOwnerMayEdit(obj)` and + // `obj.fields`. And `sharing.controlledByParent.ownerMayEdit` is quoted as + // a string literal in the off-column verdict text, so it presents as a + // receiver `sharing` reading a key `controlledByParent`. + detail: "the owner arm's own `{ optedIn, ownerColumn }` fold of one detail, not an authored surface.", + sharing: "the opt-in's spelling `sharing.controlledByParent.ownerMayEdit`, a string literal in the owner arm's message text — not a receiver.", }; const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ @@ -1781,7 +1789,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te 'bodies', 'entries', 'targets', 'owners', 'extensions', 'byCarrier', 'writers', 'tiedWriters', 'merged', 'baseFields', 'blamed', // #25760: the controlled-by-parent owner arm's own collections and the predicate string. - 'cbpDetailsWithoutOptIn', // the detail names without the opt-in — JS Set methods. + 'cbpDetails', // #26591: the resolving details by name — JS Map methods. 'policies', // one set's `rowLevelSecurity` records — `.length`, a JS property. 'predicate', // a policy's `using` / `check` text, a string — `.trim`. ]); @@ -2010,6 +2018,29 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record }], }, }, + { + // [#26591] The same rule's second push site: an opted-in detail whose + // `update` policy keys the caller on its own user field, which the opt-in + // (the stored `owner_id` only) does not admit. + label: 'cbp-caller-policy-off-owner-column', + stack: { + objects: [ + objectFixture({ name: 'invoice', sharingModel: 'private' }), + objectFixture({ + name: 'line_item', sharingModel: 'controlled_by_parent', + sharing: { controlledByParent: { ownerMayEdit: true } }, + fields: { + invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true }, + assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' }, + }, + }), + ], + permissions: [{ + name: 'ps', label: 'PS', objects: {}, + rowLevelSecurity: [{ name: 'assigned_lines', object: 'line_item', operation: 'update', using: 'assignee == current_user.id', enabled: true }], + }], + }, + }, { label: 'grant-expired-at-authoring', stack: { data: [{ object: 'sys_user_position', records: [{ user_id: 'u1', position: 'p', valid_until: '2020-01-01T00:00:00Z' }] }] }, @@ -2033,7 +2064,8 @@ describe('validateSecurityPosture — every branch is reachable without an undec // 16 since #23268: the external dial's alias and wider-than-internal sites left with the key. // 17 since #25575: allowDelete: false beside modifyAllRecords: true. // 18 since #25760: an owner-keyed update policy on a controlled_by_parent detail that has not opted in. - expect(pushedRuleIds()).toHaveLength(18); + // 19 since #26591: the same rule id, for a policy keyed only on a column the opt-in does not admit. + expect(pushedRuleIds()).toHaveLength(19); }); it('reaches every `findings.push` site from that corpus', () => { diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 6f7ce759dac..f52007d93fa 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -21,7 +21,7 @@ * | security-cbp-no-relation (error) | #7503 (runtime refusal #7474) | * | security-cbp-ambiguous-relation(error) | #14747 order-dependent master; runtime refusal since #23100 | * | security-modify-all-delete-withheld(error) | #25575 Modify All includes delete — UNPARSED intakes only, see the arm | - * | security-controlled-by-parent-owner-policy-without-opt-in (warning) | ADR-0055 §3 amendment (ruling 6398566164): an owner-keyed update policy on a detail that has not opted in cannot admit the owner | + * | security-controlled-by-parent-owner-policy-without-opt-in (warning) | ADR-0055 §3 amendment (ruling 6398566164): a caller-keyed update policy on a detail admits nobody the master gate refuses — unless it keys `owner_id` and the detail opted in (#26591: the opt-in is prescribed only there) | * * Per ADR-0049 discipline these are NOT advisory security: every `error` rule * mirrors a runtime enforcement point (D1 fail-closed OWD default, D4 @@ -124,6 +124,7 @@ import { parseCelToAst, sqlPredicateToCel, type CelAstNode } from '@objectstack/ import { controlledByParentOwnerMayEdit, isControlledByParentMasterField, referenceCarrierOf } from '@objectstack/spec/data'; import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security'; import { indexObjectGraph, nearestName, packagesOf, recordsOf, type ObjectGraph } from './object-graph.js'; +import { injectedColumnsFor } from './system-fields.js'; export const SECURITY_OWD_UNSET = 'security-owd-unset'; export const SECURITY_OWD_ALIAS = 'security-owd-alias'; @@ -468,9 +469,35 @@ function callerIdentityKeyedFields(predicate: unknown): string[] { return fields; } +/** + * [#26591] The one column the ADR-0055 §3 owner leg compares the caller + * against: `assertControlledByParentWrite` admits the caller whose id equals + * the stored row's `owner_id`, and `resolveCbpRelation` resolves the opt-in + * only where the registered schema carries that column. The runtime's + * `OWNER_COLUMN` (`security-plugin-cbp-relation.ts`), one spelling. + */ +const CBP_OWNER_COLUMN = 'owner_id'; + +/** + * [#26591] Does this detail carry the column the opt-in admits? The runtime + * asks the REGISTERED schema's field map, which holds the registry's injected + * columns and the author's own; at author time that is the spec's injection + * plan (`injectedColumnsFor`: `owner_id` under `ownership: 'user'`, the + * default, and never under `'business_unit' | 'org' | 'none'`, `managedBy` or + * a `sys_*` name) plus a field the author declared under that name. + */ +function carriesCbpOwnerColumn(obj: AnyRec): boolean { + return ( + injectedColumnsFor(obj).has(CBP_OWNER_COLUMN) || + recordsOf(obj.fields).some((f) => f.name === CBP_OWNER_COLUMN) + ); +} + /** * `security-controlled-by-parent-owner-policy-without-opt-in` for one policy, - * at `path` (the predicate that keys on the caller). + * at `path` (the predicate that keys on the caller), when the predicate keys + * `owner_id` on a detail that carries it and has not opted in: the one shape + * the opt-in turns into an admission, so the hint prescribes it. */ function pushCbpOwnerPolicyWithoutOptIn( findings: SecurityFinding[], @@ -499,6 +526,51 @@ function pushCbpOwnerPolicyWithoutOptIn( }); } +/** + * [#26591] The same rule, when the predicate keys the caller only on columns + * the opt-in does not admit: another user field (`owner == current_user.id`), + * or `owner_id` on a detail that carries no such column. The opt-in cannot + * serve this policy, declared or not, so ⛔ the hint never prescribes it (a + * remedy the runtime does not honour is the defect this arm was narrowed for); + * it names the one that works: the accountable person keyed on `owner_id`. + */ +function pushCbpCallerPolicyOffOwnerColumn( + findings: SecurityFinding[], + psName: string, + path: string, + policyName: string, + objName: string, + fields: readonly string[], + detail: { readonly optedIn: boolean; readonly ownerColumn: boolean }, +): void { + const keyed = fields.map((f) => `${f} == current_user.id`).join(', '); + const admitsOnly = + `admits only the row's stored ${CBP_OWNER_COLUMN}` + + (detail.ownerColumn ? '' : `, a column "${objName}" does not carry`); + findings.push({ + severity: 'warning', + rule: SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN, + where: `permission set "${psName}"`, + path, + // One verdict sentence, in the two states of the opt-in; why a detail's + // own policy can only narrow is this id's `os explain` entry. + message: + `update policy "${policyName}" keys on the caller (${keyed}) on controlled_by_parent detail ` + + `"${objName}", so it never admits that user: ` + + (detail.optedIn + ? `the opt-in it declares (sharing.controlledByParent.ownerMayEdit) ${admitsOnly}, every other write ` + + `needs edit access to the master, and this policy can only narrow master editors` + : `every write of a detail needs edit access to its master, this policy can only narrow master ` + + `editors, and the opt-in sharing.controlledByParent.ownerMayEdit ${admitsOnly}`), + // One remedy line. + hint: detail.ownerColumn + ? `Key the accountable person on ${CBP_OWNER_COLUMN} (the one column the master gate compares the caller ` + + `against), or drop the policy` + : `Give "${objName}" an ${CBP_OWNER_COLUMN} column (ownership: 'user', the default) and key the accountable ` + + `person on it, or drop the policy`, + }); +} + /** * `security-controlled-by-parent-no-relation` for one object, at `path`. One * site for the declared object and the merged one @@ -841,18 +913,26 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // (only the owner among the master's editors), and no runtime door refuses // the shape — the write is refused per row, by the master gate, as it was. // + // [#26591] The opt-in admits ONE column: the stored row's `owner_id`, and + // only on a detail that carries it (`resolveCbpRelation` resolves it `false` + // otherwise). So the arm prescribes it only for a predicate keying the + // caller on `owner_id` there; a predicate keyed only on another user field + // (`owner == current_user.id`), or on an `owner_id` the detail lacks, is + // flagged whether or not the detail opted in, with the remedy that works — + // key the accountable person on `owner_id` — and ⛔ never with the opt-in. + // // Judged on the detail the policy names, as declared in THIS stack, and // only where its master resolves (an object that derives no master is // refused whole by the two rules above). A wildcard policy names no detail. // The opt-in is read through the spec's one reader, raw or parsed, so the // unparsed doors (`os lint` on a raw config) read it as the parse does. - const cbpDetailsWithoutOptIn = new Set(); + const cbpDetails = new Map(); for (const obj of objects) { if (!obj || typeof obj !== 'object' || typeof obj.name !== 'string') continue; if (owdOf(obj) !== 'controlled_by_parent' || !resolveCbpRelation(obj)) continue; - if (!controlledByParentOwnerMayEdit(obj)) cbpDetailsWithoutOptIn.add(obj.name); + cbpDetails.set(obj.name, { optedIn: controlledByParentOwnerMayEdit(obj), ownerColumn: carriesCbpOwnerColumn(obj) }); } - if (cbpDetailsWithoutOptIn.size > 0) { + if (cbpDetails.size > 0) { for (let i = 0; i < permissionSets.length; i++) { const ps = permissionSets[i]; if (!ps || typeof ps !== 'object') continue; @@ -861,20 +941,21 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } for (let j = 0; j < policies.length; j++) { const policy = policies[j]; if (!policy || typeof policy !== 'object' || policy.enabled === false) continue; - if (typeof policy.object !== 'string' || !cbpDetailsWithoutOptIn.has(policy.object)) continue; + const detailName = policy.object; + const detail = typeof detailName === 'string' ? cbpDetails.get(detailName) : undefined; + if (typeof detailName !== 'string' || !detail) continue; if (!UPDATE_CLASS_RLS_OPERATIONS.has(String(policy.operation ?? ''))) continue; const policyName = typeof policy.name === 'string' ? policy.name : `(policy ${j})`; for (const predicate of ['using', 'check'] as const) { const fields = callerIdentityKeyedFields(policy[predicate]); if (fields.length === 0) continue; - pushCbpOwnerPolicyWithoutOptIn( - findings, - psName, - `permissions[${i}].rowLevelSecurity[${j}].${predicate}`, - policyName, - policy.object, - fields, - ); + const path = `permissions[${i}].rowLevelSecurity[${j}].${predicate}`; + // The opt-in admits the caller this predicate names only through + // `owner_id`, on a detail that carries it. + const admittable = detail.ownerColumn && fields.includes(CBP_OWNER_COLUMN); + if (admittable && detail.optedIn) continue; + if (admittable) pushCbpOwnerPolicyWithoutOptIn(findings, psName, path, policyName, detailName, fields); + else pushCbpCallerPolicyOffOwnerColumn(findings, psName, path, policyName, detailName, fields, detail); } } } diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index d630af9da80..4c678acb7c1 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -172,8 +172,8 @@ "children": { "ownerMayEdit": { "status": "live", - "verifiedAt": "2026-10-26", - "evidence": "packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite (the owner leg: on a by-id `update` of a detail whose relation carries `ownerMayEdit`, a non-delegated caller who is the stored row's `owner_id` and can READ the master row passes the master-write gate; every other write takes the ADR-0055 §3 chain walk); packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts#resolveCbpRelation (resolves `ownerMayEdit` beside the relation, `false` and reported once when the schema carries no `owner_id`); packages/lint/src/validate-security-posture.ts#callerIdentityKeyedFields (the lint arm `security-controlled-by-parent-owner-policy-without-opt-in` flags an owner-keyed update policy on a detail that has not declared `ownerMayEdit`)", + "verifiedAt": "2026-10-27", + "evidence": "packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite (the owner leg: on a by-id `update` of a detail whose relation carries `ownerMayEdit`, a non-delegated caller who is the stored row's `owner_id` and can READ the master row passes the master-write gate; every other write takes the ADR-0055 §3 chain walk); packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts#resolveCbpRelation (resolves `ownerMayEdit` beside the relation, `false` and reported once when the schema carries no `owner_id`); packages/lint/src/validate-security-posture.ts#callerIdentityKeyedFields (the lint arm `security-controlled-by-parent-owner-policy-without-opt-in` prescribes `ownerMayEdit` only for an update policy keyed on `owner_id` on a detail that carries that column and has not declared it, and flags a policy keyed only on another user field, declared or not, with the `owner_id` remedy)", "note": "ADR-0055 §3 amendment (#25760, ruling 6398566164): the owner of a detail row updates it by id with read on the master. Pinned through the real SecurityPlugin + ObjectQL over SqlDriver in packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts: an owner who reads the master is admitted and the row changes; a non-owner, a delete, an insert, a predicate update, a delegated write and an owner who cannot read the master are refused by the master gate; a detail without the opt-in refuses the owner as before." } }