1717 * whole environment (200), and `DELETE /packages/:id` reached the door's own
1818 * organization check (400 `TENANT_SCOPE_REQUIRED`) instead of the gate's 403.
1919 *
20+ * The same held for a set the left organization bound to one of its own
21+ * POSITIONS: with no tenant the position read is installation-wide, so the left
22+ * organization's copy of `org_member` fed its bindings to anyone still an
23+ * `org_member` somewhere.
24+ *
2025 * The rule now: with no active organization only the global grants apply, and
2126 * a grant scoped to an organization applies only while that organization is the
2227 * active tenant (`resolveUserAuthzGrants`, `@objectstack/core`).
@@ -75,8 +80,10 @@ const TABLES: Record<string, any[]> = {
7580 { user_id : 'u_exmember' , organization_id : BETA , role : 'member' } ,
7681 { user_id : 'u_global' , organization_id : BETA , role : 'member' } ,
7782 { user_id : 'u_admin' , organization_id : ALPHA , role : 'owner' } ,
83+ { user_id : 'u_posex' , organization_id : BETA , role : 'member' } ,
84+ { user_id : 'u_posmember' , organization_id : ALPHA , role : 'member' } ,
7885 ] ,
79- sys_user : [ 'u_member' , 'u_exmember' , 'u_gone' , 'u_global' , 'u_admin' ]
86+ sys_user : [ 'u_member' , 'u_exmember' , 'u_gone' , 'u_global' , 'u_admin' , 'u_posex' , 'u_posmember' ]
8087 . map ( ( id ) => ( { id, email : `${ id } @example.com` } ) ) ,
8188 sys_user_permission_set : [
8289 { user_id : 'u_member' , permission_set_id : 'ps_meta' , organization_id : ALPHA } ,
@@ -88,10 +95,22 @@ const TABLES: Record<string, any[]> = {
8895 sys_permission_set : [
8996 { id : 'ps_meta' , name : 'alpha_metadata_editors' , system_permissions : [ 'manage_metadata' , 'studio.access' ] } ,
9097 { id : 'ps_admin' , name : 'admin_full_access' , system_permissions : [ 'manage_metadata' , 'studio.access' , 'setup.access' ] } ,
98+ { id : 'ps_members' , name : 'alpha_member_tools' , system_permissions : [ 'manage_metadata' ] } ,
99+ ] ,
100+ // The per-organization copies of the `org_member` built-in a walled catalog
101+ // holds; org_alpha bound a metadata-editing set to ITS copy. This double
102+ // ignores the read's tenant, which is exactly what an organization-less
103+ // resolution's installation-wide `sys_position` read does.
104+ sys_position : [
105+ { id : 'pos_member_alpha' , name : 'org_member' , organization_id : ALPHA } ,
106+ { id : 'pos_member_beta' , name : 'org_member' , organization_id : BETA } ,
107+ ] ,
108+ sys_position_permission_set : [
109+ { position_id : 'pos_member_alpha' , permission_set_id : 'ps_members' } ,
91110 ] ,
92111} ;
93112
94- type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' ;
113+ type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember' ;
95114
96115/** Every session but the last names `org_alpha`. */
97116const SESSIONS : Record < Who , { id : string ; token : string ; userId : string ; activeOrganizationId ?: string } > = {
@@ -101,6 +120,8 @@ const SESSIONS: Record<Who, { id: string; token: string; userId: string; activeO
101120 global : { id : 'ses_global' , token : 'tok_global' , userId : 'u_global' , activeOrganizationId : ALPHA } ,
102121 admin : { id : 'ses_admin' , token : 'tok_admin' , userId : 'u_admin' , activeOrganizationId : ALPHA } ,
103122 admin_orgless : { id : 'ses_admin2' , token : 'tok_admin2' , userId : 'u_admin' } ,
123+ posex : { id : 'ses_posex' , token : 'tok_posex' , userId : 'u_posex' , activeOrganizationId : ALPHA } ,
124+ posmember : { id : 'ses_posmember' , token : 'tok_posmember' , userId : 'u_posmember' , activeOrganizationId : ALPHA } ,
104125} ;
105126
106127function rig ( ) {
@@ -185,6 +206,9 @@ afterEach(() => { warnSpy.mockRestore(); });
185206const REMOVED : Array < [ Who , string ] > = [
186207 [ 'exmember' , 'a member removed from org_alpha, still a member of org_beta' ] ,
187208 [ 'gone' , 'a member removed from org_alpha, with no membership left anywhere' ] ,
209+ // The same set reached through a POSITION: org_alpha bound it to its own
210+ // copy of `org_member`, and this member is still an `org_member` elsewhere.
211+ [ 'posex' , 'a member removed from org_alpha whose org_member binding there carried manage_metadata' ] ,
188212] ;
189213
190214describe ( '[#20515] a removed member\'s organization-scoped manage_metadata no longer passes the /packages gate' , ( ) => {
@@ -225,6 +249,13 @@ describe('[#20515] what the rule leaves unchanged', () => {
225249 expect ( r . deleteRequests ) . toEqual ( [ { packageId : PKG , organizationId : ALPHA } ] ) ;
226250 } ) ;
227251
252+ it ( 'CONTROL · the position-bound set: a current org_alpha member with org_alpha active passes: 200' , async ( ) => {
253+ const r = rig ( ) ;
254+ const answer = await r . call ( 'DELETE' , 'posmember' , `/packages/${ PKG } ` ) ;
255+ expect ( answer . status ) . toBe ( 200 ) ;
256+ expect ( r . deleteRequests ) . toEqual ( [ { packageId : PKG , organizationId : ALPHA } ] ) ;
257+ } ) ;
258+
228259 it ( 'a GLOBAL grant still passes the gate for the removed member — the door\'s own organization check answers, not the gate' , async ( ) => {
229260 const r = rig ( ) ;
230261 const answer = await r . call ( 'DELETE' , 'global' , `/packages/${ PKG } ` ) ;
0 commit comments