2222 * 3. Nothing is recorded where no wall was composed: a system context (the
2323 * middleware's first exit) and a by-id write (no predicate to compose
2424 * onto). Absence is a distinct state from `none`.
25+ * 4. [#15887] The four shapes that were CORRECT BY CONSTRUCTION but pinned
26+ * only one layer away — `systemFields.tenant: false` beside an
27+ * author-declared column, the #7835 phantom anchor, a custom
28+ * `tenancy.tenantField`, and the ADR-0090 D10 on-behalf-of INTERSECTION,
29+ * whose middleware line carried no pin at all. Each rested on the
30+ * projection's own pins (`security-plugin.test.ts`,
31+ * `federated-tenant-layer0.test.ts`, `tenant-layer.test.ts`), i.e. on the
32+ * identity「the recorded verdict IS what the predicate was projected from」
33+ * — the very identity that would break first. The pins below read the
34+ * RECORDED object, so a divergence is caught on the recording side.
2535 *
2636 * Harness: `deployment-platform-global-exemption.test.ts` — a SecurityPlugin
2737 * over a fake ObjectQL. The registered middleware is captured and driven with
3040 */
3141
3242import { describe , it , expect , vi } from 'vitest' ;
43+ import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/metadata-core' ;
3344import type { PermissionSet } from '@objectstack/spec/security' ;
3445import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity' ;
3546import { SecurityPlugin } from './security-plugin.js' ;
@@ -70,21 +81,139 @@ const localSchema = (name: string, extra: Record<string, unknown> = {}) => ({
7081 ...extra ,
7182} ) ;
7283
84+ /** `localSchema` plus extra columns — the custom-tenant-column shapes need one. */
85+ const withFields = (
86+ schema : Record < string , unknown > ,
87+ fields : Record < string , unknown > ,
88+ ) : Record < string , unknown > => ( {
89+ ...schema ,
90+ fields : { ...( schema . fields as Record < string , unknown > ) , ...fields } ,
91+ } ) ;
92+
7393const SCHEMAS : Record < string , Record < string , unknown > > = {
7494 crm_task : localSchema ( 'crm_task' ) ,
7595 sys_widget_registry : localSchema ( 'sys_widget_registry' ) ,
7696 sys_catalog : localSchema ( 'sys_catalog' , { tenancy : { enabled : false } } ) ,
7797 crm_secret : localSchema ( 'crm_secret' , { access : { default : 'private' } } ) ,
98+
99+ // ── [#15887] the three object shapes ──────────────────────────────────────
100+ //
101+ // P1. The object opted OUT of the tenant system field while the author's own
102+ // `organization_id` stays declared and perfectly readable.
103+ // `getObjectSecurityMeta` folds `systemFields.tenant === false` into
104+ // `tenancyDisabled` beside `tenancy.enabled === false`, so the wall composes
105+ // NOTHING here. The readable column is the trap: a reader answering from the
106+ // column instead of from the wall stamps this batch with the caller's
107+ // organization — a MISLABEL, on rows the wall never constrained.
108+ shared_catalog : localSchema ( 'shared_catalog' , { systemFields : { tenant : false } } ) ,
109+
110+ // [#7835] Federated, carrying the anchor `applySystemFields` injects —
111+ // spread from the shipped constant exactly as the registry does
112+ // (`additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }`), so
113+ // `hasPhantomTenantAnchor` reads the PLATFORM's provenance and Layer 0 treats
114+ // the object as carrying no tenant column. The platform issues no DDL for an
115+ // `external` object, so that column exists in the registry and nowhere else.
116+ ext_customer : {
117+ name : 'ext_customer' ,
118+ external : { remoteName : 'customers' } ,
119+ fields : {
120+ organization_id : { ...TENANT_SCOPE_FIELD_DEF } ,
121+ title : { type : 'text' , label : 'Title' } ,
122+ status : { type : 'text' , label : 'Status' } ,
123+ } ,
124+ } ,
125+ // The provenance control, federated too: this `organization_id` is the
126+ // AUTHOR's — a real remote column — so the wall is doing real work and stays.
127+ // The phantom exit is about PROVENANCE, never about `external != null`.
128+ ext_ledger : {
129+ name : 'ext_ledger' ,
130+ external : { remoteName : 'ledgers' } ,
131+ fields : {
132+ organization_id : { type : 'text' , label : 'Org (a real remote column)' } ,
133+ title : { type : 'text' , label : 'Title' } ,
134+ status : { type : 'text' , label : 'Status' } ,
135+ } ,
136+ } ,
137+
138+ // A custom tenant column declared BESIDE the kernel one — the wall keys on
139+ // the literal `organization_id` and never reads `tenancy.tenantField`.
140+ workspace_doc : withFields ( localSchema ( 'workspace_doc' , { tenancy : { tenantField : 'workspace_id' } } ) , {
141+ workspace_id : { type : 'text' , label : 'Workspace' } ,
142+ } ) ,
143+ // The same declaration on an object that carries NO `organization_id`: the
144+ // custom column is never read as a stand-in, so there is no wall to record.
145+ workspace_note : {
146+ name : 'workspace_note' ,
147+ tenancy : { tenantField : 'workspace_id' } ,
148+ fields : {
149+ workspace_id : { type : 'text' , label : 'Workspace' } ,
150+ title : { type : 'text' , label : 'Title' } ,
151+ status : { type : 'text' , label : 'Status' } ,
152+ } ,
153+ } ,
78154} ;
79155
80- async function boot ( opts : { entitlement ?: Record < string , unknown > ; tenancy ?: { posture : string } ; sets ?: PermissionSet [ ] } = { } ) {
156+ /**
157+ * [#15887 / ADR-0090 D10] Seed for the on-behalf-of leg: the `sys_*` rows the
158+ * delegator resolution reads (`resolveDelegatorContext` -> `buildContextForUser`
159+ * -> core's `resolveUserAuthzGrants`). `memberOf` becomes `sys_member` rows,
160+ * which is where the delegator's OWN `accessible_org_ids` come from — and that
161+ * set is the one Layer 0 input a delegated context does NOT inherit from the
162+ * live principal, so it is the only way the two walls can differ at all.
163+ * An empty `memberOf` still seeds the `sys_user` row: a MISSING delegator is a
164+ * different contract (a fail-closed refusal before any wall is composed).
165+ */
166+ type DelegatorSeed = { userId : string ; memberOf : string [ ] } ;
167+
168+ async function boot ( opts : {
169+ entitlement ?: Record < string , unknown > ;
170+ tenancy ?: { posture : string } ;
171+ sets ?: PermissionSet [ ] ;
172+ delegator ?: DelegatorSeed ;
173+ } = { } ) {
81174 const middlewares : Array < ( opCtx : any , next : ( ) => Promise < void > ) => Promise < void > > = [ ] ;
175+ // [#15887] Only the delegated leg needs a readable store; without a seed the
176+ // tables are empty and `findOne` answers `null` for every object exactly as
177+ // before, so the cases above are byte-identical. `find` is added ONLY under a
178+ // seed — the non-delegated path never issues one, and a `ql` without `find`
179+ // is what the earlier cases were measured against.
180+ const del = opts . delegator ;
181+ const tables : Record < string , Record < string , unknown > [ ] > = del
182+ ? {
183+ sys_user : [ { id : del . userId , email : `${ del . userId } @example.test` } ] ,
184+ sys_member : del . memberOf . map ( ( organization_id ) => ( { user_id : del . userId , organization_id } ) ) ,
185+ }
186+ : { } ;
187+ // Plain equality is all the delegator resolution ever asks for (`{ id }`,
188+ // `{ user_id }`). A combinator read as a FIELD NAME would match nothing and
189+ // say nothing, so this double REFUSES what it does not implement rather than
190+ // answering quietly — `check:where-matcher` refuses exactly the quiet shape.
191+ const matches = ( row : Record < string , unknown > , where : Record < string , unknown > | undefined ) : boolean =>
192+ Object . entries ( where ?? { } ) . every ( ( [ k , v ] ) => {
193+ if ( k . startsWith ( '$' ) ) throw new Error ( `fake engine: unsupported combinator ${ k } ` ) ;
194+ if ( v && typeof v === 'object' ) throw new Error ( `fake engine: unsupported operator on '${ k } '` ) ;
195+ return row [ k ] === v ;
196+ } ) ;
197+ const rowsOf = ( object : string , where : Record < string , unknown > | undefined ) =>
198+ ( tables [ object ] ?? [ ] ) . filter ( ( r ) => matches ( r , where ) ) ;
82199 const services : Record < string , unknown > = {
83200 manifest : { register : vi . fn ( ) } ,
84201 objectql : {
85202 registerMiddleware : ( mw : any ) => middlewares . push ( mw ) ,
86203 getSchema : ( name : string ) => SCHEMAS [ name ] ,
87- findOne : vi . fn ( async ( ) => null ) ,
204+ findOne : vi . fn ( async ( object : string , o : any ) => rowsOf ( object , o ?. where ) [ 0 ] ?? null ) ,
205+ ...( del
206+ ? {
207+ // The caller's bound is applied AFTER the filter and BY PRESENCE:
208+ // core's grants resolution hands every one of these reads a `limit`,
209+ // and a double that silently ignores it cannot report what the real
210+ // engine would (`check:objectql-double-limit`).
211+ find : async ( object : string , o : any ) => {
212+ const rows = rowsOf ( object , o ?. where ) ;
213+ return typeof o ?. limit === 'number' ? rows . slice ( 0 , o . limit ) : rows ;
214+ } ,
215+ }
216+ : { } ) ,
88217 } ,
89218 metadata : {
90219 get : async ( _type : string , name : string ) => SCHEMAS [ name ] ,
@@ -123,6 +252,18 @@ function sweep(object: string, operation: 'update' | 'delete', context: Record<s
123252 return opCtx ;
124253}
125254
255+ /**
256+ * [#15887] A READ — the other half of the `opCtx.ast` branch the verdict is
257+ * recorded in. Needed for the fail-closed delegated leg: a predicate WRITE by a
258+ * principal whose delegator holds no organization scope is refused by the
259+ * ADR-0123 D2 write check BEFORE the wall is composed, so the write shape could
260+ * not observe the recorded verdict there at all.
261+ */
262+ function readSweep ( object : string , context : Record < string , unknown > ) {
263+ const where = { status : 'open' } ;
264+ return { object, operation : 'find' , context : { ...context } , options : { where } , ast : { where } } as any ;
265+ }
266+
126267const hasVerdict = ( opCtx : any ) => Object . prototype . hasOwnProperty . call ( opCtx , 'tenantLayer0Verdict' ) ;
127268const injectedOrgWall = ( opCtx : any ) : unknown => {
128269 // The wall is AND-ed under the caller's predicate; find the organization_id clause.
@@ -137,6 +278,22 @@ const injectedOrgWall = (opCtx: any): unknown => {
137278 } ;
138279 return walk ( opCtx . ast ?. where ) ;
139280} ;
281+ /**
282+ * [#15887] EVERY `organization_id` clause in the composed tree, in composition
283+ * order. The on-behalf-of leg injects TWO walls (the caller's, then the
284+ * delegator's — `extra` is pushed in that order and spread into one `$and`),
285+ * and the single-hit walker above would report only the first.
286+ */
287+ const injectedOrgWalls = ( opCtx : any ) : unknown [ ] => {
288+ const out : unknown [ ] = [ ] ;
289+ const walk = ( node : any ) : void => {
290+ if ( ! node || typeof node !== 'object' ) return ;
291+ if ( 'organization_id' in node ) out . push ( node . organization_id ) ;
292+ for ( const part of node . $and ?? [ ] ) walk ( part ) ;
293+ } ;
294+ walk ( opCtx . ast ?. where ) ;
295+ return out ;
296+ } ;
140297
141298describe ( '[#15813] the middleware records the Layer 0 verdict it composed — one pass, verdict and predicate agree' , ( ) => {
142299 it ( '`isolated`, a member with an active organization: `organization`, and the injected wall is that equality' , async ( ) => {
@@ -263,3 +420,157 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is
263420 expect ( await ( plugin as any ) . getReadFilter ( 'sys_catalog' , MEMBER_CTX ) ) . toBeUndefined ( ) ;
264421 } ) ;
265422} ) ;
423+
424+ // ---------------------------------------------------------------------------
425+ // [#15887] The four shapes that were correct BY CONSTRUCTION and pinned only
426+ // one layer away. Nothing below claims any of them is broken — every one of
427+ // them holds on `main` today. What was missing is a reading on the RECORDING
428+ // side: each rested on the projection's own pins, i.e. on the identity 「the
429+ // verdict recorded is the object the predicate was projected from」. That
430+ // identity is exactly what a future change to this seam would break first, and
431+ // when it breaks, the projection-side pins stay green.
432+ //
433+ // So each case reads `opCtx.tenantLayer0Verdict` — the recorded object itself,
434+ // never the downstream filter and never `getReadFilter`'s projection — off ONE
435+ // middleware pass, and reads the injected predicate beside it as the control.
436+ // ---------------------------------------------------------------------------
437+ describe ( '[#15887] the three object shapes record their verdict HERE, not one layer away' , ( ) => {
438+ it ( '`systemFields.tenant: false` beside an AUTHOR-DECLARED `organization_id` records `none` (P1) — a readable column is not a wall' , async ( ) => {
439+ const { middleware } = await boot ( ) ;
440+ // The fixture's premise, asserted rather than recalled: the column really
441+ // is there to be misread. (That the REGISTRY leaves an authored column
442+ // standing under this opt-out is the registry's own fact, pinned where the
443+ // registry lives; here it is the input.)
444+ expect ( ( SCHEMAS . shared_catalog . fields as Record < string , unknown > ) . organization_id ) . toBeDefined ( ) ;
445+
446+ const opCtx = sweep ( 'shared_catalog' , 'update' , MEMBER_CTX ) ;
447+ await middleware ( opCtx , async ( ) => { } ) ;
448+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
449+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
450+
451+ // Firing control on the same boot: the sibling with no opt-out IS walled,
452+ // so `none` above is this object's verdict and not a dead middleware.
453+ const sibling = sweep ( 'crm_task' , 'update' , MEMBER_CTX ) ;
454+ await middleware ( sibling , async ( ) => { } ) ;
455+ expect ( sibling . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
456+ } ) ;
457+
458+ it ( '[#7835] a FEDERATED object carrying the PLATFORM\'s injected anchor records `none` — the phantom column is not a wall' , async ( ) => {
459+ const { middleware } = await boot ( ) ;
460+ const opCtx = sweep ( 'ext_customer' , 'update' , MEMBER_CTX ) ;
461+ await middleware ( opCtx , async ( ) => { } ) ;
462+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
463+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
464+ } ) ;
465+
466+ it ( '[#7835] a FEDERATED object whose AUTHOR declared a real remote `organization_id` records `organization` — the exit is PROVENANCE, not `external`' , async ( ) => {
467+ // The half that keeps the phantom exit from becoming "suppress Layer 0 for
468+ // every federated object", which would delete a wall that is doing its job.
469+ const { middleware } = await boot ( ) ;
470+ const opCtx = sweep ( 'ext_ledger' , 'update' , MEMBER_CTX ) ;
471+ await middleware ( opCtx , async ( ) => { } ) ;
472+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
473+ expect ( injectedOrgWall ( opCtx ) ) . toBe ( 'org-1' ) ;
474+ } ) ;
475+
476+ it ( 'a custom `tenancy.tenantField` is NOT an exit by itself: still `organization`, and the wall names `organization_id`' , async ( ) => {
477+ // Layer 0 keys on the literal `organization_id` and never reads
478+ // `tenancy.tenantField`. An object declaring a custom tenant column while
479+ // still carrying the kernel one is walled on the kernel one — so the
480+ // recorded verdict names the organization, and the injected predicate names
481+ // `organization_id`, never `workspace_id`.
482+ const { middleware } = await boot ( ) ;
483+ const opCtx = sweep ( 'workspace_doc' , 'update' , MEMBER_CTX ) ;
484+ await middleware ( opCtx , async ( ) => { } ) ;
485+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
486+ expect ( injectedOrgWall ( opCtx ) ) . toBe ( 'org-1' ) ;
487+ expect ( JSON . stringify ( opCtx . ast . where ) ) . not . toContain ( 'workspace_id' ) ;
488+ } ) ;
489+
490+ it ( 'a custom `tenancy.tenantField` on an object carrying NO `organization_id` records `none` — the custom column is never a substitute' , async ( ) => {
491+ // The other direction of the same fact: the declaration does not make
492+ // `workspace_id` a tenant column the wall can key on, so with the kernel
493+ // column absent there is no wall — and `none` is the honest verdict, not a
494+ // wall silently relocated onto the author's column.
495+ const { middleware } = await boot ( ) ;
496+ expect ( ( SCHEMAS . workspace_note . fields as Record < string , unknown > ) . organization_id ) . toBeUndefined ( ) ;
497+ const opCtx = sweep ( 'workspace_note' , 'update' , MEMBER_CTX ) ;
498+ await middleware ( opCtx , async ( ) => { } ) ;
499+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
500+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
501+ } ) ;
502+ } ) ;
503+
504+ describe ( '[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at the middleware line' , ( ) => {
505+ // `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`.
506+ // What had no pin is the LINE that calls it: that after a delegated pass the
507+ // recorded verdict is the intersection of the two walls the middleware
508+ // AND-composed — not the caller's half, which is what the site would record
509+ // if that call were ever dropped. These cases therefore assert something
510+ // NEITHER wall states on its own, which a re-run of the unit assertion in
511+ // this file could not do.
512+ const DELEGATOR = 'u2' ;
513+ // `group` is the only posture in which the two walls can differ: the
514+ // delegator's `accessible_org_ids` are resolved from ITS OWN memberships and
515+ // are deliberately not inherited, while `tenantId` is (so under `isolated`
516+ // both halves resolve to the same organization by construction).
517+ const GROUP = { posture : 'group' } ;
518+ const CALLER_ORGS = [ 'org-1' , 'org-2' , 'org-3' ] ;
519+
520+ it ( 'the recorded verdict is the INTERSECTION — an organization set neither wall names alone' , async ( ) => {
521+ const { middleware } = await boot ( {
522+ tenancy : GROUP ,
523+ delegator : { userId : DELEGATOR , memberOf : [ 'org-2' , 'org-3' , 'org-9' ] } ,
524+ } ) ;
525+ const opCtx = sweep ( 'crm_task' , 'update' , {
526+ ...MEMBER_CTX ,
527+ accessible_org_ids : CALLER_ORGS ,
528+ onBehalfOf : { userId : DELEGATOR } ,
529+ } ) ;
530+ await middleware ( opCtx , async ( ) => { } ) ;
531+
532+ // Both walls really were composed onto this one operation, in order.
533+ expect ( injectedOrgWalls ( opCtx ) ) . toEqual ( [
534+ { $in : [ 'org-1' , 'org-2' , 'org-3' ] } ,
535+ { $in : [ 'org-2' , 'org-3' , 'org-9' ] } ,
536+ ] ) ;
537+ // And the recorded verdict is what a row must satisfy to clear BOTH — a
538+ // set that is neither injected clause. Dropping the intersection at the
539+ // call site records the caller's three organizations while the composed
540+ // predicate admits two: the recorded verdict would then over-state the
541+ // batch's reach, on a wall the middleware itself narrowed.
542+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organizations' , organizationIds : [ 'org-2' , 'org-3' ] } ) ;
543+ } ) ;
544+
545+ it ( 'the SAME fixture without the delegation link records the caller\'s half — the link is what moves the answer' , async ( ) => {
546+ // Identical boot and identical caller, minus `onBehalfOf`: the case above
547+ // is a reading about the intersection, not about the `group` posture.
548+ const { middleware } = await boot ( {
549+ tenancy : GROUP ,
550+ delegator : { userId : DELEGATOR , memberOf : [ 'org-2' , 'org-3' , 'org-9' ] } ,
551+ } ) ;
552+ const opCtx = sweep ( 'crm_task' , 'update' , { ...MEMBER_CTX , accessible_org_ids : CALLER_ORGS } ) ;
553+ await middleware ( opCtx , async ( ) => { } ) ;
554+ expect ( injectedOrgWalls ( opCtx ) ) . toEqual ( [ { $in : [ 'org-1' , 'org-2' , 'org-3' ] } ] ) ;
555+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organizations' , organizationIds : CALLER_ORGS } ) ;
556+ } ) ;
557+
558+ it ( 'a delegator with NO membership makes the recorded verdict `deny` — the composed wall fails closed, the caller\'s half does not' , async ( ) => {
559+ // A READ (see `readSweep`): the write twin is refused by the ADR-0123 D2
560+ // check before the wall is composed, so the write shape cannot observe this
561+ // at all. The caller's own half names three organizations; the delegator's
562+ // empty access set denies; the AND of the two admits no row, and the
563+ // recorded verdict says so rather than naming an organization.
564+ const { middleware } = await boot ( {
565+ tenancy : GROUP ,
566+ delegator : { userId : DELEGATOR , memberOf : [ ] } ,
567+ } ) ;
568+ const opCtx = readSweep ( 'crm_task' , {
569+ ...MEMBER_CTX ,
570+ accessible_org_ids : CALLER_ORGS ,
571+ onBehalfOf : { userId : DELEGATOR } ,
572+ } ) ;
573+ await middleware ( opCtx , async ( ) => { } ) ;
574+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'deny' } ) ;
575+ } ) ;
576+ } ) ;
0 commit comments