3232// PRECONDITION tests below assert each link rather than assuming it.
3333//
3434// The user shape is ordinary, not contrived: a permission-set grant
35- // (`sys_user_permission_set`) is independent of organization MEMBERSHIP, and
36- // `resolveUserAuthzGrants` keeps an org-scoped grant when the caller has no
37- // active org to compare it against (`!(org && tenantId && org !== tenantId)`).
38- // A multi-org deployment (whose membership reconciler binds nobody — ADR-0093
35+ // (`sys_user_permission_set`) is independent of organization MEMBERSHIP. A
36+ // multi-org deployment (whose membership reconciler binds nobody — ADR-0093
3937// D1 `no-target-org`), an `invite-only` deployment, a user removed from their
40- // organization, or an SSO JIT user pending placement all produce it.
38+ // organization, or an SSO JIT user pending placement all produce a caller with
39+ // no active organization.
40+ //
41+ // [#20515] What such a caller HOLDS changed underneath this file. When #8158
42+ // was filed, `resolveUserAuthzGrants` kept an ORGANIZATION-scoped grant for a
43+ // caller with no active organization (`!(org && tenantId && org !== tenantId)`
44+ // read "no tenant" as "every organization"), so an org-scoped `manage_sharing`
45+ // reached `adminOrgScope`. It no longer does: with no active organization only
46+ // GLOBAL grants apply. So the two faces are pinned separately below —
47+ // - the EXPOSED persona holds `manage_sharing` through a GLOBAL grant, the one
48+ // way an org-less caller still holds it, and keeps pinning `adminOrgScope`
49+ // (#8158's defence in depth: capability held, no organization to use it in);
50+ // - the ORG-SCOPED persona holds the very grant #8158 was filed with, scoped
51+ // to tenant A, and is now refused at the ADR-0111 D6 capability gate.
4152//
4253// ## Anti-vacuity
4354//
@@ -63,6 +74,7 @@ const RULE_B = 'rule_8158_tenant_b';
6374const PASSWORD = 'Member-Pass-123' ;
6475const ORG_LESS_EMAIL = 'orgless-8158@verify.test' ;
6576const ORG_BOUND_EMAIL = 'orgbound-8158@verify.test' ;
77+ const ORG_SCOPED_ORG_LESS_EMAIL = 'orgscoped-orgless-8158@verify.test' ;
6678
6779interface RuleRow {
6880 id : string ;
@@ -76,11 +88,14 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
7688 let ql : any ;
7789 /** The harness admin: platform authority, and (by harness design) org-less. */
7890 let platform : string ;
79- /** The exposed persona: org-scoped `manage_sharing`, no membership, no active org. */
91+ /** The exposed persona: GLOBAL `manage_sharing`, no membership, no active org. */
8092 let orgLess : string ;
81- /** The control persona: the SAME grant, plus a membership in tenant A. */
93+ /** [#20515] The #8158 grant as filed — scoped to tenant A — with no membership and no active org. */
94+ let orgScopedOrgLess : string ;
95+ /** The control persona: the same set scoped to tenant A, plus a membership in tenant A. */
8296 let orgBound : string ;
8397 let orgLessUserId = '' ;
98+ let orgScopedOrgLessUserId = '' ;
8499 let orgBoundUserId = '' ;
85100
86101 const ruleRow = ( organizationId : string , name : string ) => ( {
@@ -126,13 +141,23 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
126141 // Real sign-ups: better-auth's own path, through every database hook.
127142 await stack . signUp ( ORG_LESS_EMAIL , PASSWORD ) ;
128143 await stack . signUp ( ORG_BOUND_EMAIL , PASSWORD ) ;
144+ await stack . signUp ( ORG_SCOPED_ORG_LESS_EMAIL , PASSWORD ) ;
129145 const uid = async ( email : string ) : Promise < string > =>
130146 ( await ql . findOne ( 'sys_user' , { where : { email } , context : SYS } ) ) ?. id ;
131147 orgLessUserId = await uid ( ORG_LESS_EMAIL ) ;
132148 orgBoundUserId = await uid ( ORG_BOUND_EMAIL ) ;
149+ orgScopedOrgLessUserId = await uid ( ORG_SCOPED_ORG_LESS_EMAIL ) ;
133150
134- // The identical grant for both, scoped to tenant A.
135- for ( const userId of [ orgLessUserId , orgBoundUserId ] ) {
151+ // [#20515] The exposed persona holds the set GLOBALLY (no organization):
152+ // with no active organization only global grants apply, so this is the one
153+ // spelling under which it still carries `manage_sharing` and still reaches
154+ // `adminOrgScope`.
155+ await ql . insert ( 'sys_user_permission_set' , {
156+ user_id : orgLessUserId , permission_set_id : psId , organization_id : null ,
157+ } , { context : SYS } ) ;
158+ // The #8158 grant as filed — scoped to tenant A — for the control (which
159+ // has tenant A active) and for the org-scoped persona (which has none).
160+ for ( const userId of [ orgBoundUserId , orgScopedOrgLessUserId ] ) {
136161 await ql . insert ( 'sys_user_permission_set' , {
137162 user_id : userId , permission_set_id : psId , organization_id : ORG_A ,
138163 } , { context : SYS } ) ;
@@ -148,6 +173,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
148173 // with the membership state above already in place.
149174 orgLess = await stack . signIn ( ORG_LESS_EMAIL , PASSWORD ) ;
150175 orgBound = await stack . signIn ( ORG_BOUND_EMAIL , PASSWORD ) ;
176+ orgScopedOrgLess = await stack . signIn ( ORG_SCOPED_ORG_LESS_EMAIL , PASSWORD ) ;
151177 } , 180_000 ) ;
152178
153179 afterAll ( async ( ) => {
@@ -206,6 +232,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
206232 // ── the measurement ──────────────────────────────────────────────────
207233
208234 it ( 'THE REPORTED CASE: listing is refused 403, not answered with every tenant’s rules' , async ( ) => {
235+ // The exposed persona holds `manage_sharing` GLOBALLY (#20515 — see the
236+ // header), so this is `adminOrgScope` refusing, as the next case proves.
209237 const res = await stack . apiAs ( orgLess , 'GET' , RULES ) ;
210238 const payload = res . status === 200
211239 ? ( ( await res . json ( ) ) as { data : RuleRow [ ] } ) . data . map ( ( r ) => `${ r . name } @${ r . organization_id } ` )
@@ -274,6 +302,37 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
274302 } ) ) . toBeFalsy ( ) ;
275303 } ) ;
276304
305+ // ── [#20515] the #8158 grant as filed: ORG-scoped, no active organization ───
306+
307+ it ( 'PRECONDITION: the org-scoped persona holds no membership, and its SESSION carries no active organization' , async ( ) => {
308+ const members = await ql . find ( 'sys_member' , { where : { user_id : orgScopedOrgLessUserId } , context : SYS } ) ;
309+ expect ( Array . isArray ( members ) ? members : members ?. records ?? [ ] ) . toHaveLength ( 0 ) ;
310+ const sessions = await ql . find ( 'sys_session' , { where : { user_id : orgScopedOrgLessUserId } , context : SYS } ) ;
311+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312+ const rows : any [ ] = Array . isArray ( sessions ) ? sessions : sessions ?. records ?? [ ] ;
313+ expect ( rows . length , 'the sign-in really did mint a session row' ) . toBeGreaterThan ( 0 ) ;
314+ for ( const s of rows ) {
315+ expect ( s . active_organization_id ?? s . activeOrganizationId ?? null ) . toBeFalsy ( ) ;
316+ }
317+ } ) ;
318+
319+ it ( 'an ORG-scoped manage_sharing grant with no active organization is refused 403 at the CAPABILITY gate' , async ( ) => {
320+ // With no active organization only global grants apply, so the tenant-A
321+ // grant confers nothing here: the ADR-0111 D6 gate refuses before
322+ // `adminOrgScope` is ever asked — and no tenant's rule is read.
323+ const res = await stack . apiAs ( orgScopedOrgLess , 'GET' , RULES ) ;
324+ const body = ( await res . json ( ) ) as { code ?: string ; error ?: string ; data ?: RuleRow [ ] } ;
325+ expect ( res . status , JSON . stringify ( body ) ) . toBe ( 403 ) ;
326+ expect ( body . code ) . toBe ( 'PERMISSION_DENIED' ) ;
327+ expect ( body . error ?? '' ) . toMatch ( / r e q u i r e s t h e m a n a g e _ s h a r i n g c a p a b i l i t y / ) ;
328+ expect ( body . data ) . toBeUndefined ( ) ;
329+ } ) ;
330+
331+ it ( 'the org-scoped persona is refused the other tenant’s rule by name as well' , async ( ) => {
332+ const res = await stack . apiAs ( orgScopedOrgLess , 'GET' , `${ RULES } /${ RULE_B } ` ) ;
333+ expect ( res . status , await res . text ( ) ) . toBe ( 403 ) ;
334+ } ) ;
335+
277336 // ── the control: the SAME grant, with an organization, still works ───
278337
279338 it ( 'the org-BOUND holder of the same grant reads its own tenant and NOT the other' , async ( ) => {
0 commit comments