Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/authz-secfollowup-explain-posture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): explain posture 证据对齐 enforcement 派生(消除标签漂移)

Security review 低危项。explain-engine 的 `derivePosture(context)` 之前用**松名字匹配**作
posture 证据——`permissions.includes(ADMIN_FULL_ACCESS)`(不校验非作用域)+ `positions.includes(
'org_owner'/'org_admin')`(读 better-auth 角色),比 enforcement(`resolve-authz-context.ts` 的
`hasPlatformAdminGrant`:要求**非作用域 admin_full_access user grant**;TENANT_ADMIN 用
`organization_admin` **能力**而非角色)更松,可能让 explain 面板给运维显示**偏高**的 posture 标签
(作用域 org-admin grant 被误标 PLATFORM/TENANT_ADMIN)。

修法——让 explain 的 posture 走 enforcement 已用的同一份证据:

- **优先直接消费 `ctx.posture`**:principal 经完整 `resolveAuthzContext` 时已带 enforcement 派生的
rung,逐字返回 → 结构上不可能漂移。
- **回退(explain 用 `buildContextForUser` 自建 context,不经完整 resolveAuthzContext)**:复制
enforcement 的非作用域 grant 判定——`buildContextForUser` 现按与 `hasPlatformAdminGrant` 逐字节
一致的规则(`admin_full_access` 且 `organization_id == null` 的 active user grant)计算并挂出
`hasPlatformAdminGrant`;`derivePosture` 以此 + 投影出的 `platform_admin` 内建岗位判 PLATFORM_ADMIN,
以 `organization_admin` **能力**判 TENANT_ADMIN,**不再**读 `org_owner`/`org_admin` 角色岗位
(ADR-0095 D3:角色只是 provisioning 来源,非裁决输入 — explain 侧同样闭合 #2836 dual-track)。
- 保留 explain 特有的 **guest → EXTERNAL** 底(enforcement floor 是 MEMBER),且置于最前。

只改 explain 的 **posture 标签**证据,不改 explain 的 allow/deny verdict(来自复用的 enforcement
filter),不改 enforcement。#2947 跟踪的「posture 未 plumb 进 enforcement context」更广缺口不在本
任务范围。关联 #2920。
83 changes: 83 additions & 0 deletions packages/plugins/plugin-security/src/explain-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,60 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
});
});

describe('posture derivation aligns with enforcement (label-drift elimination)', () => {
// posture is surfaced whenever a recordId is supplied; the record-grained deps
// are irrelevant to the posture value, so the base object-level deps suffice.
const postureOf = async (context: any): Promise<string | undefined> => {
const d = await explainAccess(makeDeps({ sets: [ADMIN] }), {
object: 'leave_request',
operation: 'read',
context,
recordId: 'r1',
});
return d.principal.posture;
};

it('reuses ctx.posture verbatim when enforcement already resolved it', async () => {
// A principal resolved through resolveAuthzContext carries ctx.posture — the
// explain panel must echo it, never re-derive a different tier.
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: [], posture: 'PLATFORM_ADMIN' })).toBe('PLATFORM_ADMIN');
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: [], posture: 'TENANT_ADMIN' })).toBe('TENANT_ADMIN');
// Explicit posture wins over what the loose fallback would have guessed.
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'], posture: 'MEMBER' })).toBe('MEMBER');
});

it('a merely-SCOPED admin_full_access grant no longer over-labels as PLATFORM_ADMIN', async () => {
// Name present in `permissions` but NOT held as an unscoped grant and NOT the
// projected platform_admin position → MEMBER, matching enforcement (which
// requires hasPlatformAdminGrant = unscoped admin_full_access user grant).
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'] })).toBe('MEMBER');
});

it('the unscoped-grant flag (hasPlatformAdminGrant) DOES yield PLATFORM_ADMIN', async () => {
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'], hasPlatformAdminGrant: true })).toBe('PLATFORM_ADMIN');
});

it('the projected platform_admin built-in position still yields PLATFORM_ADMIN', async () => {
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['platform_admin', 'everyone'], permissions: [] })).toBe('PLATFORM_ADMIN');
});

it('org_owner / org_admin better-auth role positions no longer confer TENANT_ADMIN (ADR-0095 D3)', async () => {
// The role is a provisioning source, not posture evidence. Absent the
// organization_admin CAPABILITY these resolve to MEMBER, as enforcement does.
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['org_admin', 'everyone'], permissions: [] })).toBe('MEMBER');
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['org_owner', 'everyone'], permissions: [] })).toBe('MEMBER');
});

it('the organization_admin capability grant yields TENANT_ADMIN', async () => {
expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['organization_admin'] })).toBe('TENANT_ADMIN');
});

it('guest / anonymous → EXTERNAL floor wins even over an attached posture', async () => {
expect(await postureOf({ userId: 'a1', principalKind: 'guest', positions: [], permissions: [], posture: 'PLATFORM_ADMIN' })).toBe('EXTERNAL');
expect(await postureOf({ userId: null, positions: [], permissions: [] })).toBe('EXTERNAL');
});
});

describe('buildContextForUser', () => {
const ql = {
async find(object: string, opts: any) {
Expand All @@ -314,6 +368,34 @@ describe('buildContextForUser', () => {
},
};

it('derives hasPlatformAdminGrant from an UNSCOPED admin_full_access user grant (matches resolveAuthzContext)', async () => {
const qlUnscoped = {
async find(object: string, _opts: any) {
if (object === 'sys_user_permission_set') return [{ user_id: 'u2', permission_set_id: 'psAdmin' /* organization_id absent → unscoped */ }];
if (object === 'sys_permission_set') return [{ id: 'psAdmin', name: 'admin_full_access' }];
return [];
},
};
const ctx = await buildContextForUser(qlUnscoped, 'u2');
expect(ctx.hasPlatformAdminGrant).toBe(true);
expect(ctx.permissions).toContain('admin_full_access');
});

it('a SCOPED (org-specific) admin_full_access user grant does NOT set hasPlatformAdminGrant', async () => {
const qlScoped = {
async find(object: string, _opts: any) {
if (object === 'sys_user_permission_set') return [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }];
if (object === 'sys_permission_set') return [{ id: 'psAdmin', name: 'admin_full_access' }];
return [];
},
};
const ctx = await buildContextForUser(qlScoped, 'u2');
expect(ctx.hasPlatformAdminGrant).toBe(false);
// The name is still resolved into permissions (it grants object CRUD), but it
// no longer confers platform_admin posture — the drift this closes.
expect(ctx.permissions).toContain('admin_full_access');
});

it('reconstructs positions + direct grants + the everyone anchor', async () => {
const ctx = await buildContextForUser(ql, 'u2');
expect(ctx).toEqual({
Expand All @@ -322,6 +404,7 @@ describe('buildContextForUser', () => {
permissions: ['payroll_reader'],
expiredGrants: [],
delegatedPositions: [],
hasPlatformAdminGrant: false,
});
});

Expand Down
80 changes: 62 additions & 18 deletions packages/plugins/plugin-security/src/explain-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,56 @@ function kernelTierOf(layer: ExplainLayer['layer']): 'layer_0_tenant' | 'layer_1
return layer === 'tenant_isolation' ? 'layer_0_tenant' : 'layer_1_business';
}

const AUTHZ_POSTURES: ReadonlySet<string> = new Set<AuthzPosture>([
'PLATFORM_ADMIN',
'TENANT_ADMIN',
'MEMBER',
'EXTERNAL',
]);
function isAuthzPosture(v: unknown): v is AuthzPosture {
return typeof v === 'string' && AUTHZ_POSTURES.has(v);
}

/**
* [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung. The
* PLATFORM_ADMIN / TENANT_ADMIN / MEMBER tiers delegate to the single core
* derivation ({@link deriveAdminPosture}, `@objectstack/core`) so the admin-tier
* logic has ONE source of truth (B4/D3 — derived from held capability grants,
* never a better-auth role, closing the #2836 dual-track by construction).
* [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung, using the SAME
* evidence enforcement uses so the explain panel's tier can never sit HIGHER
* than the runtime's (label-drift elimination — security review low-severity).
*
* Explain layers one thing on top the enforcement resolver deliberately does
* NOT: an anonymous / guest principal is represented as EXTERNAL for the
* debugger. The enforcement posture resolver's floor is MEMBER (no external
* principal type exists yet — ADR-0095 D2), so this guest→EXTERNAL mapping lives
* here, in the explanation surface, rather than in `resolveAuthzContext`.
* Order of preference:
*
* 1. **guest / anonymous → EXTERNAL.** Explain layers one thing on top the
* enforcement resolver deliberately does NOT: a guest principal is EXTERNAL
* for the debugger. The enforcement floor is MEMBER (no external principal
* type exists yet — ADR-0095 D2), so this mapping lives here, and it wins
* first.
* 2. **Reuse `ctx.posture` verbatim when present.** A principal resolved through
* the full `resolveAuthzContext` already carries the enforcement-derived
* rung; consuming it directly makes drift structurally impossible.
* 3. **Fallback — re-derive from capability-grant evidence.** The explain API's
* `buildContextForUser` builds a context WITHOUT running the full
* `resolveAuthzContext`, so no `posture` is attached. We then derive from the
* SAME evidence `resolveAuthzContext` uses — NOT the previous loose
* permission-set-NAME match:
* - `PLATFORM_ADMIN` ← the **unscoped `admin_full_access` USER grant**
* (`hasPlatformAdminGrant`, computed by `buildContextForUser` byte-for-
* byte as `resolveAuthzContext` computes it), OR the `platform_admin`
* built-in position (which is itself only ever PROJECTED from that same
* grant — ADR-0068 D2). A merely-SCOPED `admin_full_access` grant (name
* present in `permissions`, not held unscoped) no longer over-labels.
* - `TENANT_ADMIN` ← the `organization_admin` **capability** grant, exactly
* like enforcement (ADR-0095 D3). The better-auth `org_owner`/`org_admin`
* role positions are a provisioning source only and are no longer read
* as posture evidence — closing the same #2836 dual-track explain-side.
*/
function derivePosture(context: any): AuthzPosture {
if (!context?.userId || context?.principalKind === 'guest') return 'EXTERNAL';
if (isAuthzPosture(context?.posture)) return context.posture;
const positions: string[] = Array.isArray(context?.positions) ? context.positions : [];
const permissions: string[] = Array.isArray(context?.permissions) ? context.permissions : [];
if (!context?.userId || context?.principalKind === 'guest') return 'EXTERNAL';
return deriveAdminPosture({
isPlatformAdmin:
positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN) || permissions.includes(ADMIN_FULL_ACCESS),
isTenantAdmin:
positions.includes('org_owner') ||
positions.includes('org_admin') ||
permissions.includes(ORGANIZATION_ADMIN),
context?.hasPlatformAdminGrant === true || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN),
isTenantAdmin: permissions.includes(ORGANIZATION_ADMIN),
});
}

Expand Down Expand Up @@ -206,11 +232,25 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
}
}
} catch { /* table unavailable → positions stay empty */ }
// [ADR-0095 D3 / ADR-0068 D2] platform_admin posture is DERIVED from an
// UNSCOPED (`organization_id == null`) `admin_full_access` USER grant — the
// single source of truth enforcement (`resolveAuthzContext.hasPlatformAdminGrant`)
// trusts. We compute it here with the IDENTICAL rule so the explain panel's
// posture cannot sit higher than enforcement's: a merely org-SCOPED
// admin_full_access grant must NOT confer platform_admin.
let hasPlatformAdminGrant = false;
try {
const grants = await ql.find('sys_user_permission_set', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX });
const grantRows = (Array.isArray(grants) ? grants : []) as any[];
const activeRows = grantRows.filter((g) => isGrantActive(g, nowMs));
const expiredRows = grantRows.filter((g) => !isGrantActive(g, nowMs) && isGrantExpired(g, nowMs));
// permission-set-ids held via an UNSCOPED active user grant (org == null).
const unscopedActiveIds = new Set<string>(
activeRows
.filter((g) => ((g?.organization_id ?? g?.organizationId) ?? null) === null)
.map((g: any) => String(g?.permission_set_id ?? g?.permissionSetId ?? ''))
.filter(Boolean),
);
const ids = [...activeRows, ...expiredRows].map((g: any) => g?.permission_set_id).filter(Boolean);
if (ids.length > 0) {
const sets = await ql.find('sys_permission_set', { where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX });
Expand All @@ -219,8 +259,12 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
if ((s as any)?.id && (s as any)?.name) nameById.set(String((s as any).id), String((s as any).name));
}
for (const g of activeRows) {
const n = nameById.get(String(g?.permission_set_id ?? ''));
const id = String(g?.permission_set_id ?? '');
const n = nameById.get(id);
if (n && !permissions.includes(n)) permissions.push(n);
// Same predicate as resolveAuthzContext: name is admin_full_access AND
// the granting row is an unscoped user grant.
if (n === ADMIN_FULL_ACCESS && unscopedActiveIds.has(id)) hasPlatformAdminGrant = true;
}
for (const g of expiredRows) {
const n = nameById.get(String(g?.permission_set_id ?? ''));
Expand All @@ -230,7 +274,7 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number
} catch { /* ignore */ }
// [ADR-0090 D5] Authenticated principals implicitly hold the everyone anchor.
if (!positions.includes('everyone')) positions.push('everyone');
return { userId, positions, permissions, expiredGrants, delegatedPositions };
return { userId, positions, permissions, expiredGrants, delegatedPositions, hasPlatformAdminGrant };
}

/**
Expand Down