@@ -49,30 +49,56 @@ function kernelTierOf(layer: ExplainLayer['layer']): 'layer_0_tenant' | 'layer_1
4949 return layer === 'tenant_isolation' ? 'layer_0_tenant' : 'layer_1_business' ;
5050}
5151
52+ const AUTHZ_POSTURES : ReadonlySet < string > = new Set < AuthzPosture > ( [
53+ 'PLATFORM_ADMIN' ,
54+ 'TENANT_ADMIN' ,
55+ 'MEMBER' ,
56+ 'EXTERNAL' ,
57+ ] ) ;
58+ function isAuthzPosture ( v : unknown ) : v is AuthzPosture {
59+ return typeof v === 'string' && AUTHZ_POSTURES . has ( v ) ;
60+ }
61+
5262/**
53- * [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung. The
54- * PLATFORM_ADMIN / TENANT_ADMIN / MEMBER tiers delegate to the single core
55- * derivation ({@link deriveAdminPosture}, `@objectstack/core`) so the admin-tier
56- * logic has ONE source of truth (B4/D3 — derived from held capability grants,
57- * never a better-auth role, closing the #2836 dual-track by construction).
63+ * [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung, using the SAME
64+ * evidence enforcement uses so the explain panel's tier can never sit HIGHER
65+ * than the runtime's (label-drift elimination — security review low-severity).
5866 *
59- * Explain layers one thing on top the enforcement resolver deliberately does
60- * NOT: an anonymous / guest principal is represented as EXTERNAL for the
61- * debugger. The enforcement posture resolver's floor is MEMBER (no external
62- * principal type exists yet — ADR-0095 D2), so this guest→EXTERNAL mapping lives
63- * here, in the explanation surface, rather than in `resolveAuthzContext`.
67+ * Order of preference:
68+ *
69+ * 1. **guest / anonymous → EXTERNAL.** Explain layers one thing on top the
70+ * enforcement resolver deliberately does NOT: a guest principal is EXTERNAL
71+ * for the debugger. The enforcement floor is MEMBER (no external principal
72+ * type exists yet — ADR-0095 D2), so this mapping lives here, and it wins
73+ * first.
74+ * 2. **Reuse `ctx.posture` verbatim when present.** A principal resolved through
75+ * the full `resolveAuthzContext` already carries the enforcement-derived
76+ * rung; consuming it directly makes drift structurally impossible.
77+ * 3. **Fallback — re-derive from capability-grant evidence.** The explain API's
78+ * `buildContextForUser` builds a context WITHOUT running the full
79+ * `resolveAuthzContext`, so no `posture` is attached. We then derive from the
80+ * SAME evidence `resolveAuthzContext` uses — NOT the previous loose
81+ * permission-set-NAME match:
82+ * - `PLATFORM_ADMIN` ← the **unscoped `admin_full_access` USER grant**
83+ * (`hasPlatformAdminGrant`, computed by `buildContextForUser` byte-for-
84+ * byte as `resolveAuthzContext` computes it), OR the `platform_admin`
85+ * built-in position (which is itself only ever PROJECTED from that same
86+ * grant — ADR-0068 D2). A merely-SCOPED `admin_full_access` grant (name
87+ * present in `permissions`, not held unscoped) no longer over-labels.
88+ * - `TENANT_ADMIN` ← the `organization_admin` **capability** grant, exactly
89+ * like enforcement (ADR-0095 D3). The better-auth `org_owner`/`org_admin`
90+ * role positions are a provisioning source only and are no longer read
91+ * as posture evidence — closing the same #2836 dual-track explain-side.
6492 */
6593function derivePosture ( context : any ) : AuthzPosture {
94+ if ( ! context ?. userId || context ?. principalKind === 'guest' ) return 'EXTERNAL' ;
95+ if ( isAuthzPosture ( context ?. posture ) ) return context . posture ;
6696 const positions : string [ ] = Array . isArray ( context ?. positions ) ? context . positions : [ ] ;
6797 const permissions : string [ ] = Array . isArray ( context ?. permissions ) ? context . permissions : [ ] ;
68- if ( ! context ?. userId || context ?. principalKind === 'guest' ) return 'EXTERNAL' ;
6998 return deriveAdminPosture ( {
7099 isPlatformAdmin :
71- positions . includes ( BUILTIN_IDENTITY_PLATFORM_ADMIN ) || permissions . includes ( ADMIN_FULL_ACCESS ) ,
72- isTenantAdmin :
73- positions . includes ( 'org_owner' ) ||
74- positions . includes ( 'org_admin' ) ||
75- permissions . includes ( ORGANIZATION_ADMIN ) ,
100+ context ?. hasPlatformAdminGrant === true || positions . includes ( BUILTIN_IDENTITY_PLATFORM_ADMIN ) ,
101+ isTenantAdmin : permissions . includes ( ORGANIZATION_ADMIN ) ,
76102 } ) ;
77103}
78104
@@ -206,11 +232,25 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
206232 }
207233 }
208234 } catch { /* table unavailable → positions stay empty */ }
235+ // [ADR-0095 D3 / ADR-0068 D2] platform_admin posture is DERIVED from an
236+ // UNSCOPED (`organization_id == null`) `admin_full_access` USER grant — the
237+ // single source of truth enforcement (`resolveAuthzContext.hasPlatformAdminGrant`)
238+ // trusts. We compute it here with the IDENTICAL rule so the explain panel's
239+ // posture cannot sit higher than enforcement's: a merely org-SCOPED
240+ // admin_full_access grant must NOT confer platform_admin.
241+ let hasPlatformAdminGrant = false ;
209242 try {
210243 const grants = await ql . find ( 'sys_user_permission_set' , { where : { user_id : userId } , limit : 500 , context : SYSTEM_CTX } ) ;
211244 const grantRows = ( Array . isArray ( grants ) ? grants : [ ] ) as any [ ] ;
212245 const activeRows = grantRows . filter ( ( g ) => isGrantActive ( g , nowMs ) ) ;
213246 const expiredRows = grantRows . filter ( ( g ) => ! isGrantActive ( g , nowMs ) && isGrantExpired ( g , nowMs ) ) ;
247+ // permission-set-ids held via an UNSCOPED active user grant (org == null).
248+ const unscopedActiveIds = new Set < string > (
249+ activeRows
250+ . filter ( ( g ) => ( ( g ?. organization_id ?? g ?. organizationId ) ?? null ) === null )
251+ . map ( ( g : any ) => String ( g ?. permission_set_id ?? g ?. permissionSetId ?? '' ) )
252+ . filter ( Boolean ) ,
253+ ) ;
214254 const ids = [ ...activeRows , ...expiredRows ] . map ( ( g : any ) => g ?. permission_set_id ) . filter ( Boolean ) ;
215255 if ( ids . length > 0 ) {
216256 const sets = await ql . find ( 'sys_permission_set' , { where : { id : { $in : ids } } , limit : ids . length , context : SYSTEM_CTX } ) ;
@@ -219,8 +259,12 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
219259 if ( ( s as any ) ?. id && ( s as any ) ?. name ) nameById . set ( String ( ( s as any ) . id ) , String ( ( s as any ) . name ) ) ;
220260 }
221261 for ( const g of activeRows ) {
222- const n = nameById . get ( String ( g ?. permission_set_id ?? '' ) ) ;
262+ const id = String ( g ?. permission_set_id ?? '' ) ;
263+ const n = nameById . get ( id ) ;
223264 if ( n && ! permissions . includes ( n ) ) permissions . push ( n ) ;
265+ // Same predicate as resolveAuthzContext: name is admin_full_access AND
266+ // the granting row is an unscoped user grant.
267+ if ( n === ADMIN_FULL_ACCESS && unscopedActiveIds . has ( id ) ) hasPlatformAdminGrant = true ;
224268 }
225269 for ( const g of expiredRows ) {
226270 const n = nameById . get ( String ( g ?. permission_set_id ?? '' ) ) ;
@@ -230,7 +274,7 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
230274 } catch { /* ignore */ }
231275 // [ADR-0090 D5] Authenticated principals implicitly hold the everyone anchor.
232276 if ( ! positions . includes ( 'everyone' ) ) positions . push ( 'everyone' ) ;
233- return { userId, positions, permissions, expiredGrants, delegatedPositions } ;
277+ return { userId, positions, permissions, expiredGrants, delegatedPositions, hasPlatformAdminGrant } ;
234278}
235279
236280/**
0 commit comments