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,120 @@ 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+ const matches = ( row : Record < string , unknown > , where : Record < string , unknown > | undefined ) : boolean =>
188+ Object . entries ( where ?? { } ) . every ( ( [ k , v ] ) => row [ k ] === v ) ;
189+ const rowsOf = ( object : string , where : Record < string , unknown > | undefined ) =>
190+ ( tables [ object ] ?? [ ] ) . filter ( ( r ) => matches ( r , where ) ) ;
82191 const services : Record < string , unknown > = {
83192 manifest : { register : vi . fn ( ) } ,
84193 objectql : {
85194 registerMiddleware : ( mw : any ) => middlewares . push ( mw ) ,
86195 getSchema : ( name : string ) => SCHEMAS [ name ] ,
87- findOne : vi . fn ( async ( ) => null ) ,
196+ findOne : vi . fn ( async ( object : string , o : any ) => rowsOf ( object , o ?. where ) [ 0 ] ?? null ) ,
197+ ...( del ? { find : async ( object : string , o : any ) => rowsOf ( object , o ?. where ) } : { } ) ,
88198 } ,
89199 metadata : {
90200 get : async ( _type : string , name : string ) => SCHEMAS [ name ] ,
@@ -123,6 +233,18 @@ function sweep(object: string, operation: 'update' | 'delete', context: Record<s
123233 return opCtx ;
124234}
125235
236+ /**
237+ * [#15887] A READ — the other half of the `opCtx.ast` branch the verdict is
238+ * recorded in. Needed for the fail-closed delegated leg: a predicate WRITE by a
239+ * principal whose delegator holds no organization scope is refused by the
240+ * ADR-0123 D2 write check BEFORE the wall is composed, so the write shape could
241+ * not observe the recorded verdict there at all.
242+ */
243+ function readSweep ( object : string , context : Record < string , unknown > ) {
244+ const where = { status : 'open' } ;
245+ return { object, operation : 'find' , context : { ...context } , options : { where } , ast : { where } } as any ;
246+ }
247+
126248const hasVerdict = ( opCtx : any ) => Object . prototype . hasOwnProperty . call ( opCtx , 'tenantLayer0Verdict' ) ;
127249const injectedOrgWall = ( opCtx : any ) : unknown => {
128250 // The wall is AND-ed under the caller's predicate; find the organization_id clause.
@@ -137,6 +259,22 @@ const injectedOrgWall = (opCtx: any): unknown => {
137259 } ;
138260 return walk ( opCtx . ast ?. where ) ;
139261} ;
262+ /**
263+ * [#15887] EVERY `organization_id` clause in the composed tree, in composition
264+ * order. The on-behalf-of leg injects TWO walls (the caller's, then the
265+ * delegator's — `extra` is pushed in that order and spread into one `$and`),
266+ * and the single-hit walker above would report only the first.
267+ */
268+ const injectedOrgWalls = ( opCtx : any ) : unknown [ ] => {
269+ const out : unknown [ ] = [ ] ;
270+ const walk = ( node : any ) : void => {
271+ if ( ! node || typeof node !== 'object' ) return ;
272+ if ( 'organization_id' in node ) out . push ( node . organization_id ) ;
273+ for ( const part of node . $and ?? [ ] ) walk ( part ) ;
274+ } ;
275+ walk ( opCtx . ast ?. where ) ;
276+ return out ;
277+ } ;
140278
141279describe ( '[#15813] the middleware records the Layer 0 verdict it composed — one pass, verdict and predicate agree' , ( ) => {
142280 it ( '`isolated`, a member with an active organization: `organization`, and the injected wall is that equality' , async ( ) => {
@@ -263,3 +401,157 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is
263401 expect ( await ( plugin as any ) . getReadFilter ( 'sys_catalog' , MEMBER_CTX ) ) . toBeUndefined ( ) ;
264402 } ) ;
265403} ) ;
404+
405+ // ---------------------------------------------------------------------------
406+ // [#15887] The four shapes that were correct BY CONSTRUCTION and pinned only
407+ // one layer away. Nothing below claims any of them is broken — every one of
408+ // them holds on `main` today. What was missing is a reading on the RECORDING
409+ // side: each rested on the projection's own pins, i.e. on the identity 「the
410+ // verdict recorded is the object the predicate was projected from」. That
411+ // identity is exactly what a future change to this seam would break first, and
412+ // when it breaks, the projection-side pins stay green.
413+ //
414+ // So each case reads `opCtx.tenantLayer0Verdict` — the recorded object itself,
415+ // never the downstream filter and never `getReadFilter`'s projection — off ONE
416+ // middleware pass, and reads the injected predicate beside it as the control.
417+ // ---------------------------------------------------------------------------
418+ describe ( '[#15887] the three object shapes record their verdict HERE, not one layer away' , ( ) => {
419+ it ( '`systemFields.tenant: false` beside an AUTHOR-DECLARED `organization_id` records `none` (P1) — a readable column is not a wall' , async ( ) => {
420+ const { middleware } = await boot ( ) ;
421+ // The fixture's premise, asserted rather than recalled: the column really
422+ // is there to be misread. (That the REGISTRY leaves an authored column
423+ // standing under this opt-out is the registry's own fact, pinned where the
424+ // registry lives; here it is the input.)
425+ expect ( ( SCHEMAS . shared_catalog . fields as Record < string , unknown > ) . organization_id ) . toBeDefined ( ) ;
426+
427+ const opCtx = sweep ( 'shared_catalog' , 'update' , MEMBER_CTX ) ;
428+ await middleware ( opCtx , async ( ) => { } ) ;
429+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
430+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
431+
432+ // Firing control on the same boot: the sibling with no opt-out IS walled,
433+ // so `none` above is this object's verdict and not a dead middleware.
434+ const sibling = sweep ( 'crm_task' , 'update' , MEMBER_CTX ) ;
435+ await middleware ( sibling , async ( ) => { } ) ;
436+ expect ( sibling . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
437+ } ) ;
438+
439+ it ( '[#7835] a FEDERATED object carrying the PLATFORM\'s injected anchor records `none` — the phantom column is not a wall' , async ( ) => {
440+ const { middleware } = await boot ( ) ;
441+ const opCtx = sweep ( 'ext_customer' , 'update' , MEMBER_CTX ) ;
442+ await middleware ( opCtx , async ( ) => { } ) ;
443+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
444+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
445+ } ) ;
446+
447+ it ( '[#7835] a FEDERATED object whose AUTHOR declared a real remote `organization_id` records `organization` — the exit is PROVENANCE, not `external`' , async ( ) => {
448+ // The half that keeps the phantom exit from becoming "suppress Layer 0 for
449+ // every federated object", which would delete a wall that is doing its job.
450+ const { middleware } = await boot ( ) ;
451+ const opCtx = sweep ( 'ext_ledger' , 'update' , MEMBER_CTX ) ;
452+ await middleware ( opCtx , async ( ) => { } ) ;
453+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
454+ expect ( injectedOrgWall ( opCtx ) ) . toBe ( 'org-1' ) ;
455+ } ) ;
456+
457+ it ( 'a custom `tenancy.tenantField` is NOT an exit by itself: still `organization`, and the wall names `organization_id`' , async ( ) => {
458+ // Layer 0 keys on the literal `organization_id` and never reads
459+ // `tenancy.tenantField`. An object declaring a custom tenant column while
460+ // still carrying the kernel one is walled on the kernel one — so the
461+ // recorded verdict names the organization, and the injected predicate names
462+ // `organization_id`, never `workspace_id`.
463+ const { middleware } = await boot ( ) ;
464+ const opCtx = sweep ( 'workspace_doc' , 'update' , MEMBER_CTX ) ;
465+ await middleware ( opCtx , async ( ) => { } ) ;
466+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organization' , organizationId : 'org-1' } ) ;
467+ expect ( injectedOrgWall ( opCtx ) ) . toBe ( 'org-1' ) ;
468+ expect ( JSON . stringify ( opCtx . ast . where ) ) . not . toContain ( 'workspace_id' ) ;
469+ } ) ;
470+
471+ it ( 'a custom `tenancy.tenantField` on an object carrying NO `organization_id` records `none` — the custom column is never a substitute' , async ( ) => {
472+ // The other direction of the same fact: the declaration does not make
473+ // `workspace_id` a tenant column the wall can key on, so with the kernel
474+ // column absent there is no wall — and `none` is the honest verdict, not a
475+ // wall silently relocated onto the author's column.
476+ const { middleware } = await boot ( ) ;
477+ expect ( ( SCHEMAS . workspace_note . fields as Record < string , unknown > ) . organization_id ) . toBeUndefined ( ) ;
478+ const opCtx = sweep ( 'workspace_note' , 'update' , MEMBER_CTX ) ;
479+ await middleware ( opCtx , async ( ) => { } ) ;
480+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'none' } ) ;
481+ expect ( injectedOrgWall ( opCtx ) ) . toBeUndefined ( ) ;
482+ } ) ;
483+ } ) ;
484+
485+ describe ( '[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at the middleware line' , ( ) => {
486+ // `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`.
487+ // What had no pin is the LINE that calls it: that after a delegated pass the
488+ // recorded verdict is the intersection of the two walls the middleware
489+ // AND-composed — not the caller's half, which is what the site would record
490+ // if that call were ever dropped. These cases therefore assert something
491+ // NEITHER wall states on its own, which a re-run of the unit assertion in
492+ // this file could not do.
493+ const DELEGATOR = 'u2' ;
494+ // `group` is the only posture in which the two walls can differ: the
495+ // delegator's `accessible_org_ids` are resolved from ITS OWN memberships and
496+ // are deliberately not inherited, while `tenantId` is (so under `isolated`
497+ // both halves resolve to the same organization by construction).
498+ const GROUP = { posture : 'group' } ;
499+ const CALLER_ORGS = [ 'org-1' , 'org-2' , 'org-3' ] ;
500+
501+ it ( 'the recorded verdict is the INTERSECTION — an organization set neither wall names alone' , async ( ) => {
502+ const { middleware } = await boot ( {
503+ tenancy : GROUP ,
504+ delegator : { userId : DELEGATOR , memberOf : [ 'org-2' , 'org-3' , 'org-9' ] } ,
505+ } ) ;
506+ const opCtx = sweep ( 'crm_task' , 'update' , {
507+ ...MEMBER_CTX ,
508+ accessible_org_ids : CALLER_ORGS ,
509+ onBehalfOf : { userId : DELEGATOR } ,
510+ } ) ;
511+ await middleware ( opCtx , async ( ) => { } ) ;
512+
513+ // Both walls really were composed onto this one operation, in order.
514+ expect ( injectedOrgWalls ( opCtx ) ) . toEqual ( [
515+ { $in : [ 'org-1' , 'org-2' , 'org-3' ] } ,
516+ { $in : [ 'org-2' , 'org-3' , 'org-9' ] } ,
517+ ] ) ;
518+ // And the recorded verdict is what a row must satisfy to clear BOTH — a
519+ // set that is neither injected clause. Dropping the intersection at the
520+ // call site records the caller's three organizations while the composed
521+ // predicate admits two: the recorded verdict would then over-state the
522+ // batch's reach, on a wall the middleware itself narrowed.
523+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organizations' , organizationIds : [ 'org-2' , 'org-3' ] } ) ;
524+ } ) ;
525+
526+ it ( 'the SAME fixture without the delegation link records the caller\'s half — the link is what moves the answer' , async ( ) => {
527+ // Identical boot and identical caller, minus `onBehalfOf`: the case above
528+ // is a reading about the intersection, not about the `group` posture.
529+ const { middleware } = await boot ( {
530+ tenancy : GROUP ,
531+ delegator : { userId : DELEGATOR , memberOf : [ 'org-2' , 'org-3' , 'org-9' ] } ,
532+ } ) ;
533+ const opCtx = sweep ( 'crm_task' , 'update' , { ...MEMBER_CTX , accessible_org_ids : CALLER_ORGS } ) ;
534+ await middleware ( opCtx , async ( ) => { } ) ;
535+ expect ( injectedOrgWalls ( opCtx ) ) . toEqual ( [ { $in : [ 'org-1' , 'org-2' , 'org-3' ] } ] ) ;
536+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'organizations' , organizationIds : CALLER_ORGS } ) ;
537+ } ) ;
538+
539+ it ( 'a delegator with NO membership makes the recorded verdict `deny` — the composed wall fails closed, the caller\'s half does not' , async ( ) => {
540+ // A READ (see `readSweep`): the write twin is refused by the ADR-0123 D2
541+ // check before the wall is composed, so the write shape cannot observe this
542+ // at all. The caller's own half names three organizations; the delegator's
543+ // empty access set denies; the AND of the two admits no row, and the
544+ // recorded verdict says so rather than naming an organization.
545+ const { middleware } = await boot ( {
546+ tenancy : GROUP ,
547+ delegator : { userId : DELEGATOR , memberOf : [ ] } ,
548+ } ) ;
549+ const opCtx = readSweep ( 'crm_task' , {
550+ ...MEMBER_CTX ,
551+ accessible_org_ids : CALLER_ORGS ,
552+ onBehalfOf : { userId : DELEGATOR } ,
553+ } ) ;
554+ await middleware ( opCtx , async ( ) => { } ) ;
555+ expect ( opCtx . tenantLayer0Verdict ) . toEqual ( { kind : 'deny' } ) ;
556+ } ) ;
557+ } ) ;
0 commit comments