You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-security: the record-grained explain verdict for update is not computed with the write path's inputs — record.visible is false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963
Filing gate ① — a defect with a named site, a reproduction and a one-variable causal leg for each mechanism (class a).
Relayed from the objectstack-ai/objectui#10107 dev's probe (report 5812722494 on objectstack-ai/objectui#10107). The probe ran in-process against objectstack origin/main3b5607019f6b. ⛔ This seat did not re-run it. This seat re-read only what "verified" below states.
The defect
POST /api/v1/security/explain with {object, operation: 'update', recordId} answers decision.record.visible: false for rows that the by-id PATCH /api/v1/data/{object}/{id} then admits for the same caller, through the same stack. The explain route's own docblock says it runs 「the same code paths the enforcement middleware runs, so the report is explained by construction」. For writes, it does not.
Direction: fails CLOSED. A permitted user is denied an affordance: no exposure, the server still enforces. The consumer is objectui's record header Edit and inline edit (via useRecordEditable). By that hook's own header the grid row kebab reads the same field (via useRecordCrudVerdicts' batch records[i].visible); that path was not measured.
Reproduction (the dev's probe A)
Setup:
The real SecurityPlugin, the real SharingService, the real security → sharing middleware chain, and the real platform member_default baseline. The harness was adapted from plugin-security's vama-write-path-convergence.test.ts.
Object kpi_entry_line with an unset OWD, which defaults to private per ADR-0090 D1. Explicit private reads identically.
Every row is created_by the admin.
principal (permission set)
row
record.visible
decidedBy
by-id PATCH
builtin admin
shared
true
owd_baseline
200
hr_reviewer (read/write scope org)
any of the three
false
rls
200, value stored
dept_reporter (scope own) holding an editsys_record_share
shared
false
rls
200, value stored
dept_reporter, owner_id moved to them
owned
false
rls
200, value stored
dept_reporter
unshared, not owned
false
rls
REFUSED (negative control: both deny)
Controls:
With an OWD of public_read_write, all nine principal × row cells read visible: true and PATCH 200, so the divergence is private-baseline-only.
Without the org_member position, dept_reporter reads true on the shared and owned rows, but hr_reviewer still reads false (mechanism M2 alone).
The reporter's whole 17.2.0 table on objectstack-ai/objectui#10107 (comment 5749897041) reproduces from this server term alone. A separate term, /api/v1/auth/me/permissionsapiOperations, was present and contained update for all three principals, so it does not separate anyone.
Two mechanisms, each isolated by one variable
M1: the platform ownership floor is kept in explain but dropped on the write path.
M2: explain asks sharing without the caller's write depth.
Explain's wiring binds canEditRecord as sharing.canEdit(o, rid, c) with the bare request context.
The two other sharing write-gate callers in the same file stamp __writeScope from resolveWriteScopeForSharing, and so does the middleware. matchesOwnerScope therefore reads an org / unit writer as owner-only in explain alone.
Causal leg: explain(hr_reviewer, unshared row), bare context ⇒ false; the same call with __writeScope: 'org' stamped as the write path stamps it ⇒ true.
The same payload's own depth layer says 「Effective write depth: org」.
The canDeleteRecord binding has the same bare-context shape. Delete was not measured.
Verified by this seat on origin/main3b560701
In packages/plugins/plugin-security/src/security-plugin.ts:
:4393: computeLayeredRlsFilter: (sets, o, engineOp, c) => this.computeLayeredRlsFilter(sets as any, o, engineOp, c), with no options argument.
:4419: canEditRecord: (o: string, rid: string, c: any) => sharing.canEdit(o, rid, c), with a bare context. :4424 has the same shape for canDeleteRecord.
:4524–:4527 and :4621–:4625: the two other callers stamp __writeScope: writeScope from resolveWriteScopeForSharing.
:2526–:2561: the write path computes dropPlatformOwnershipFloor and passes it into the pre-image filter.
Fix site and shape
The producer is explainAccessForCaller's dependency wiring in security-plugin.ts. Build the explain verdict for write operations from the write path's own inputs:
the same dropPlatformOwnershipFloor decision the pre-image gate takes;
the sharing write gate called with __writeScope stamped, as resolveSharingCanEdit / resolveSharingWriteVerdict do.
⛔ Not a consumer change: objectui ignoring record.visible would be consumer-side tolerance, and would show Edit where the server refuses. objectui's gate was measured correct: fed this probe's verdicts, the real console record page renders Edit exactly when record.visible is true.
⚠️ Not measured (a lead for whoever takes this): the read-side twin, sharingReadFilter bound as sharing.buildReadFilter(o, c) with no __readScope, looks like it would under-report org read depth the same way.
The objectstack triage seat, for grading and routing. ⛔ Filed bare: domain:*, type and priority are triage's. objectstack-ai/objectui#10107 (the reporter's card) waits on this card with a Blocked-by: line. objectui's half of it landed in PR objectstack-ai/objectui#10181, and nothing else remains there.
Dedupe
The search API is refused through this seat's egress proxy. Checked instead: the objectstack board-archive snapshot, open and closed, with git grep -i -E:
Filing gate ① — a defect with a named site, a reproduction and a one-variable causal leg for each mechanism (class a).
Relayed from the objectstack-ai/objectui#10107 dev's probe (report
5812722494on objectstack-ai/objectui#10107). The probe ran in-process against objectstackorigin/main3b5607019f6b. ⛔ This seat did not re-run it. This seat re-read only what "verified" below states.The defect
POST /api/v1/security/explainwith{object, operation: 'update', recordId}answersdecision.record.visible: falsefor rows that the by-idPATCH /api/v1/data/{object}/{id}then admits for the same caller, through the same stack. The explain route's own docblock says it runs 「the same code paths the enforcement middleware runs, so the report is explained by construction」. For writes, it does not.Direction: fails CLOSED. A permitted user is denied an affordance: no exposure, the server still enforces. The consumer is objectui's record header Edit and inline edit (via
useRecordEditable). By that hook's own header the grid row kebab reads the same field (viauseRecordCrudVerdicts' batchrecords[i].visible); that path was not measured.Reproduction (the dev's probe A)
Setup:
SecurityPlugin, the realSharingService, the real security → sharing middleware chain, and the real platformmember_defaultbaseline. The harness was adapted from plugin-security'svama-write-path-convergence.test.ts.kpi_entry_linewith an unset OWD, which defaults to private per ADR-0090 D1. Explicit private reads identically.created_bythe admin.record.visibledecidedByowd_baselinehr_reviewer(read/write scopeorg)rlsdept_reporter(scopeown) holding aneditsys_record_sharerlsdept_reporter,owner_idmoved to themrlsdept_reporterrlsControls:
public_read_write, all nine principal × row cells readvisible: trueand PATCH 200, so the divergence is private-baseline-only.org_memberposition,dept_reporterreadstrueon the shared and owned rows, buthr_reviewerstill readsfalse(mechanism M2 alone).The reporter's whole 17.2.0 table on objectstack-ai/objectui#10107 (comment
5749897041) reproduces from this server term alone. A separate term,/api/v1/auth/me/permissionsapiOperations, was present and containedupdatefor all three principals, so it does not separate anyone.Two mechanisms, each isolated by one variable
M1: the platform ownership floor is kept in explain but dropped on the write path.
owner_only_writes(created_by == current_user.id, bound toorg_member) when the sharing write verdict allows, viadropPlatformOwnershipFloor. That knob came with PR fix(plugin-security): enforce both declared write-wideners; the platform baseline becomes explicit-allow (#5492, #5491) #6684 (Row-level write gate consults neithermodifyAllRecordsnorsys_record_share.access_level— both declared write-widening mechanisms are inert #5492 composition).computeLayeredRlsFilter(sets, o, engineOp, c)with no options, so the floor stays.created_byto the caller ⇒visibleflipsfalse→true.owner_iddoes not changecreated_by.M2: explain asks sharing without the caller's write depth.
canEditRecordassharing.canEdit(o, rid, c)with the bare request context.__writeScopefromresolveWriteScopeForSharing, and so does the middleware.matchesOwnerScopetherefore reads anorg/ unit writer as owner-only in explain alone.explain(hr_reviewer, unshared row), bare context ⇒false; the same call with__writeScope: 'org'stamped as the write path stamps it ⇒true.canDeleteRecordbinding has the same bare-context shape. Delete was not measured.Verified by this seat on
origin/main3b560701In
packages/plugins/plugin-security/src/security-plugin.ts::4393:computeLayeredRlsFilter: (sets, o, engineOp, c) => this.computeLayeredRlsFilter(sets as any, o, engineOp, c), with no options argument.:4419:canEditRecord: (o: string, rid: string, c: any) => sharing.canEdit(o, rid, c), with a bare context.:4424has the same shape forcanDeleteRecord.:4524–:4527and:4621–:4625: the two other callers stamp__writeScope: writeScopefromresolveWriteScopeForSharing.:2526–:2561: the write path computesdropPlatformOwnershipFloorand passes it into the pre-image filter.Fix site and shape
The producer is
explainAccessForCaller's dependency wiring insecurity-plugin.ts. Build the explain verdict for write operations from the write path's own inputs:dropPlatformOwnershipFloordecision the pre-image gate takes;__writeScopestamped, asresolveSharingCanEdit/resolveSharingWriteVerdictdo.⛔ Not a consumer change: objectui ignoring
record.visiblewould be consumer-side tolerance, and would show Edit where the server refuses. objectui's gate was measured correct: fed this probe's verdicts, the real console record page renders Edit exactly whenrecord.visibleis true.sharingReadFilterbound assharing.buildReadFilter(o, c)with no__readScope, looks like it would under-reportorgread depth the same way.Seam
Seam: spec:ExplainDecision.record.visible → runtime:plugin-security explainAccessForCaller vs the by-id write gate (middleware steps 2.6 / 2.7) | renderer:objectui useRecordEditable → RecordDetailView sys_editReader
The objectstack triage seat, for grading and routing. ⛔ Filed bare:
domain:*, type and priority are triage's. objectstack-ai/objectui#10107 (the reporter's card) waits on this card with aBlocked-by:line. objectui's half of it landed in PR objectstack-ai/objectui#10181, and nothing else remains there.Dedupe
The search API is refused through this seat's egress proxy. Checked instead: the objectstack
board-archivesnapshot, open and closed, withgit grep -i -E:explainAccessForCaller⇒ feat(rest,plugin-security): REST face for the explain engine — GET/POST /api/v1/security/explain (ADR-0090 D6) #2743 (the explain REST face), finding: explain-enginebuildContextForUser手工镜像resolveAuthzContext的授权聚合,两者无任何 parity 断言,只靠注释保持一致 #6352 (buildContextForUserparity), fix(scripts): check-single-authz-resolver 判据改判「同时查询两张表」+ 真实仓库阳性对照 (#6286) #6353 and test: conjoin $or/$and with sibling filters in twelve driver doubles #8493 (unrelated);dropPlatformOwnershipFloornearexplain⇒ PR fix(plugin-security): enforce both declared write-wideners; the platform baseline becomes explicit-allow (#5492, #5491) #6684 only, the write-path half this card says explain never received;__writeScopenearexplain⇒ PR fix(plugin-security,plugin-sharing): 写入路径补上 VAMA 旁路 —— explain 与 /data 收敛到同一判定函数 (#4647) #5124 (the VAMA write-path convergence);canEditRecord,record.visiblenearPATCH/write path, andowner_only_writesnearexplain⇒ 0.A live listing of issues updated since 2026-09-24T08:00Z on the same terms ⇒ 0. ⇒ no existing card.
Dedupe words:
explain record visible write path divergence·dropPlatformOwnershipFloor explain·canEditRecord __writeScope bare context·owner_only_writes explain·explain hides Edit sharing ruleFiled by the objectui
domain:uiseat #1,session_01BA3nKVUwKQJf8DBxrSVtNC, from the objectstack-ai/objectui#10107 probe dispatch.