@@ -1195,7 +1195,7 @@ export class SecurityPlugin implements Plugin {
11951195 // closes the owner-enumeration oracle a system read would
11961196 // open (a caller who can't read the row gets null → deny,
11971197 // indistinguishable from a non-owner).
1198- const pre : any = await this . ql . findOne ( opCtx . object , { where : { id : targetId } , context : opCtx . context } ) ;
1198+ const pre = await this . getCallerPreImage ( opCtx , targetId ) ;
11991199 unchanged = ! ! pre && pre . owner_id != null && String ( pre . owner_id ) === String ( row . owner_id ) ;
12001200 } catch {
12011201 unchanged = false ; // fail closed
@@ -1257,13 +1257,10 @@ export class SecurityPlugin implements Plugin {
12571257 ) ;
12581258 postImage = null ;
12591259 } else if ( this . ql ) {
1260- let pre : any = null ;
1261- try {
1262- pre = await this . ql . findOne ( opCtx . object , { where : { id : targetId } , context : opCtx . context } ) ;
1263- } catch {
1264- pre = null ;
1265- }
1266- if ( pre && typeof pre === 'object' ) postImage = { ...( pre as Record < string , unknown > ) , ...( opCtx . data as Record < string , unknown > ) } ;
1260+ // Shares the memoized caller pre-image with the step-3.5 owner
1261+ // echo check — the identical (object, id, caller-context) row.
1262+ const pre = await this . getCallerPreImage ( opCtx , targetId ) ;
1263+ if ( pre ) postImage = { ...pre , ...( opCtx . data as Record < string , unknown > ) } ;
12671264 }
12681265 }
12691266 if ( postImage && ! checkParts . every ( ( f ) => matchesFilterCondition ( postImage as any , f as any ) ) ) {
@@ -1366,9 +1363,10 @@ export class SecurityPlugin implements Plugin {
13661363 // sorting / grouping on it (row presence is the oracle; the objectui
13671364 // /data surface makes URL-driven predicates first-class). Reject such
13681365 // queries outright — silent predicate dropping would change query
1369- // semantics unpredictably. MUST run against the CALLER's AST, before
1370- // the RLS injection below: RLS policies legitimately reference fields
1371- // the caller cannot read (e.g. owner_id).
1366+ // semantics unpredictably. MUST run against the CALLER's OWN predicate,
1367+ // before the RLS injection below: RLS / sharing filters legitimately
1368+ // reference fields the caller cannot read (e.g. owner_id) and must not be
1369+ // rejected.
13721370 if ( opCtx . ast ) {
13731371 let guardPerms = this . permissionEvaluator . getFieldPermissions ( opCtx . object , permissionSets ) ;
13741372 guardPerms = this . foldFieldRequiredPermissions ( guardPerms , secMeta . fieldRequiredPermissions , permissionSets ) ;
@@ -1380,7 +1378,22 @@ export class SecurityPlugin implements Plugin {
13801378 guardPerms = intersectFieldMasks ( guardPerms , delGuard ) ;
13811379 }
13821380 if ( Object . keys ( guardPerms ) . length > 0 ) {
1383- assertReadableQueryFields ( opCtx . ast as unknown as Record < string , unknown > , guardPerms , opCtx . object ) ;
1381+ // [#2982 follow-up] For a bulk WRITE the caller's own predicate is
1382+ // `opCtx.options.where` (untouched); `opCtx.ast.where` may ALREADY
1383+ // carry an owner-match that plugin-sharing's write branch composed in
1384+ // — and plugin-sharing is a SIBLING middleware whose registration
1385+ // order relative to this one is not guaranteed. Guarding the injected
1386+ // AST would 403 a legitimate bulk write on an object whose owner_id is
1387+ // FLS-hidden, purely because a sibling filter mentioned it. Inspect
1388+ // the caller's own predicate so the guard is independent of middleware
1389+ // order and never mistakes an injected filter for a probe. Reads keep
1390+ // guarding the ast: the read seed is the caller's query verbatim and
1391+ // this middleware runs before its own RLS injection.
1392+ const guardTarget : Record < string , unknown > =
1393+ opCtx . operation === 'update' || opCtx . operation === 'delete'
1394+ ? { where : opCtx . options ?. where }
1395+ : ( opCtx . ast as unknown as Record < string , unknown > ) ;
1396+ assertReadableQueryFields ( guardTarget , guardPerms , opCtx . object ) ;
13841397 }
13851398 }
13861399
@@ -2156,9 +2169,7 @@ export class SecurityPlugin implements Plugin {
21562169 return ;
21572170 }
21582171
2159- const existing = await this . ql
2160- . findOne ( 'sys_permission_set' , { where : { id : targetId } , context : { isSystem : true } } )
2161- . catch ( ( ) => null ) ;
2172+ const existing = await this . readRowById ( 'sys_permission_set' , targetId , { isSystem : true } ) ;
21622173 if ( existing && ( existing as Record < string , unknown > ) . managed_by === 'package' ) {
21632174 const row = existing as Record < string , unknown > ;
21642175 throw new PermissionDeniedError (
@@ -2264,9 +2275,7 @@ export class SecurityPlugin implements Plugin {
22642275 return ;
22652276 }
22662277
2267- const existing = await this . ql
2268- . findOne ( opCtx . object , { where : { id : targetId } , context : { isSystem : true } } )
2269- . catch ( ( ) => null ) ;
2278+ const existing = await this . readRowById ( opCtx . object , targetId , { isSystem : true } ) ;
22702279 const existingManagedBy = existing
22712280 ? String ( ( existing as Record < string , unknown > ) . managed_by ?? '' )
22722281 : '' ;
@@ -2297,6 +2306,40 @@ export class SecurityPlugin implements Plugin {
22972306 return null ;
22982307 }
22992308
2309+ /**
2310+ * By-id row read with the standard FAIL-CLOSED contract (missing engine,
2311+ * null id, or a thrown read → `null`), shared by every provenance / pre-image
2312+ * gate. Centralising the read SHAPE keeps a future change (soft-delete filter,
2313+ * field projection) from drifting across the ~5 call sites that used to
2314+ * inline it (#3018 review — Reuse). A `null` return always DENIES downstream;
2315+ * it never bypasses a gate.
2316+ */
2317+ private async readRowById ( object : string , id : unknown , context : any ) : Promise < Record < string , unknown > | null > {
2318+ if ( id == null || typeof this . ql ?. findOne !== 'function' ) return null ;
2319+ try {
2320+ const row = await this . ql . findOne ( object , { where : { id } , context } ) ;
2321+ return row && typeof row === 'object' ? ( row as Record < string , unknown > ) : null ;
2322+ } catch {
2323+ return null ;
2324+ }
2325+ }
2326+
2327+ /**
2328+ * The single-id write PRE-IMAGE read under the CALLER's context, memoized per
2329+ * operation. The owner-anchor echo check (step 3.5) and the RLS `check`
2330+ * post-image (step 3.6) read the IDENTICAL `(object, id, caller-context)` row;
2331+ * this collapses the two reads into one. Safe: no write to the row happens
2332+ * between the gates (the driver write runs after the whole middleware pass),
2333+ * so the pre-image is stable within the operation.
2334+ */
2335+ private async getCallerPreImage ( opCtx : any , id : unknown ) : Promise < Record < string , unknown > | null > {
2336+ if ( id == null ) return null ;
2337+ if ( opCtx . __preImage && opCtx . __preImage . id === id ) return opCtx . __preImage . row ;
2338+ const row = await this . readRowById ( opCtx . object , id , opCtx . context ) ;
2339+ opCtx . __preImage = { id, row } ;
2340+ return row ;
2341+ }
2342+
23002343 /**
23012344 * [ADR-0095 D1] Compute the effective row filter for (object, operation) as
23022345 * `Layer0(tenant) AND Layer1(business RLS)`.
@@ -2597,14 +2640,9 @@ export class SecurityPlugin implements Plugin {
25972640 } else {
25982641 const targetId = this . extractSingleId ( opCtx ) ;
25992642 if ( targetId == null ) return ; // bulk write — scoped by the read filter on the AST
2600- let row : any = null ;
2601- try {
2602- row = await this . ql . findOne ( object , { where : { id : targetId } , context : { isSystem : true } } ) ;
2603- } catch {
2604- row = null ;
2605- }
2643+ const row = await this . readRowById ( object , targetId , { isSystem : true } ) ;
26062644 if ( ! row ) deny ( 'target record not found' , targetId ) ;
2607- masterId = row [ rel ! . fk ] ;
2645+ masterId = row ! [ rel ! . fk ] ;
26082646 }
26092647 if ( masterId == null ) deny ( 'detail record has no master reference' ) ;
26102648
0 commit comments