Skip to content

Commit 8064e6d

Browse files
os-trumpclaude
andauthored
fix(plugin-auth): answer the admin permission query from the platform-authz predicate (#12210)
Shade the vendor's admin permission-query route with an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (ADR-0068 predicate instead of the retired legacy role scalar), so the admin now gets the answer real execution gives. Every other caller and every body the vendor refuses to evaluate is delegated through handleRequest, so the plain member's own negative answer, the enveloped anonymous refusal, and the vendor's validation bytes all stand unchanged. The mount shadows the vendor-declared ledger row; the standing dogfood sweep reclassifies the route from NOT_AN_AUTHORIZATION_ANSWER to ADMITTED with the answer pinned in both directions. Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33e81a5 commit 8064e6d

5 files changed

Lines changed: 634 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
`POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte.
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #11900 — `/admin/has-permission` answers a PLATFORM ADMIN from the ADR-0068
4+
// predicate, and changes NOTHING else.
5+
//
6+
// ── The defect this pins ────────────────────────────────────────────────────
7+
//
8+
// The vendor evaluates this permission QUERY on the legacy
9+
// `user.role === 'admin'` scalar ADR-0068 D2 stopped synthesizing, so a
10+
// genuine ObjectStack platform admin was answered `200 {"success":false}` —
11+
// byte-identical to a plain member. A wrong ANSWER, not a refusal: no error to
12+
// notice, and any caller trusting it as "this admin may not do X" is silently
13+
// wrong. Maintainer ruling 2026-08-25 (option B per the card body's
14+
// lettering): shade the route (#9652 pattern), answer from the predicate.
15+
//
16+
// ── Why every leg drives the REAL mount chain ───────────────────────────────
17+
//
18+
// Raw-mount-vs-catch-all ordering does not exist inside `AuthManager` — a test
19+
// driving `handleRequest` directly would bypass the mount under test entirely
20+
// (the `admin-remove-user-gate-ordering.test.ts` reading). So the fixture
21+
// mounts `AuthPlugin.registerAuthRoutes` on a real Hono app in front of a real
22+
// `AuthManager` on the installed better-auth, and every assertion reads a
23+
// status and a body off a real `Response`.
24+
//
25+
// ── Why the admin is granted, never scalared ────────────────────────────────
26+
//
27+
// ⛔ The subject is made a platform admin the ADR-0068 D2 way — an unscoped
28+
// `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is
29+
// absent. A `role = 'admin'` fixture would be answered `true` by the UNSHADED
30+
// vendor too: it passes with or without this card's change and measures
31+
// nothing (the `admin-impersonate-endpoint.test.ts` discipline).
32+
//
33+
// ── The contrast is the load-bearing half ───────────────────────────────────
34+
//
35+
// The failure was a wrong ANSWER, so the answer is asserted in BOTH
36+
// directions, twice over:
37+
//
38+
// • caller contrast — the admin's `true` means nothing unless the plain
39+
// member's own `{"error":null,"success":false}` stays exactly as it is
40+
// (a build that answers `true` for everyone satisfies the admin leg alone;
41+
// the member's `true` would be the LEAK the non-admin dogfood sweep pins);
42+
// • query contrast — the admin's `true` for a granted statement means
43+
// nothing unless an UNGRANTED one still answers `false` (a build that
44+
// echoes the predicate unconditionally satisfies the granted leg alone,
45+
// and is just a new wrong-200 pointing the other way).
46+
//
47+
// Plus the delegated remainder, unchanged: anonymous 401 (enveloped), and the
48+
// vendor's own 400 for every body shape it refuses to evaluate — asserted for
49+
// the ADMIN caller, because the shading must not put an answer where the
50+
// vendor's validation order puts a refusal.
51+
52+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
53+
import { Hono } from 'hono';
54+
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
55+
import { AuthManager } from './auth-manager';
56+
import { AuthPlugin } from './auth-plugin';
57+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
58+
import { inviteForAudienceGate } from './audience-gate-test-support';
59+
import { readEvaluatedPermissionQuery } from './admin-has-permission-endpoint';
60+
import type { PluginContext } from '@objectstack/core';
61+
62+
const SECRET = 'test-secret-at-least-32-chars-long!!';
63+
const PASSWORD = 'S3cure!Passw0rd-11900';
64+
const ORIGIN = 'http://localhost:3000';
65+
const BASE = '/api/v1/auth';
66+
const ROUTE = '/admin/has-permission';
67+
const PS_ADMIN = 'ps_admin_full_access';
68+
69+
const mockCtx = (): PluginContext =>
70+
({
71+
registerService: vi.fn(),
72+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
73+
getServices: vi.fn(() => new Map()),
74+
hook: vi.fn(),
75+
trigger: vi.fn(),
76+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
77+
getKernel: vi.fn(),
78+
}) as any;
79+
80+
/** Status, parsed JSON (when any), and the raw text for failure messages. */
81+
async function answerOf(res: Response): Promise<{ status: number; json: any; text: string }> {
82+
const text = await res.text();
83+
let json: any;
84+
try {
85+
json = JSON.parse(text);
86+
} catch {
87+
json = undefined;
88+
}
89+
return { status: res.status, json, text };
90+
}
91+
92+
/**
93+
* One deployment, served through the REAL mount chain: `admin` holds the
94+
* ADR-0068 grant (and provably NOT the scalar), `member` is a plain
95+
* authenticated user.
96+
*/
97+
async function stage() {
98+
const engine = createMemoryEngine();
99+
const manager = new AuthManager({
100+
secret: SECRET,
101+
baseUrl: ORIGIN,
102+
dataEngine: engine,
103+
plugins: { admin: true },
104+
} as any);
105+
106+
const direct = (path: string, body: unknown) =>
107+
manager.handleRequest(
108+
new Request(`${ORIGIN}${BASE}${path}`, {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
body: JSON.stringify(body),
112+
}),
113+
);
114+
115+
for (const [email, name] of [
116+
['admin.11900@example.com', 'Granted Platform Admin'],
117+
['member.11900@example.com', 'Plain Member'],
118+
]) {
119+
// [#11767] default audience posture is invite_only: fixture users beyond
120+
// the first enter through the invitation carve-out.
121+
await inviteForAudienceGate(manager, email);
122+
const res = await direct('/sign-up/email', { email, password: PASSWORD, name });
123+
expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200);
124+
}
125+
126+
const users = (engine.tables.get('sys_user') ?? []) as any[];
127+
const adminId = String(users.find((r) => r.email === 'admin.11900@example.com')!.id);
128+
129+
// The ADR-0068 D2 grant — an ORG-LESS `admin_full_access` link. ⛔ NOT the
130+
// legacy scalar (see header).
131+
await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS });
132+
await engine.insert('sys_user_permission_set', {
133+
user_id: adminId,
134+
permission_set_id: PS_ADMIN,
135+
organization_id: null,
136+
});
137+
138+
const bearerFor = async (email: string) => {
139+
const res = await direct('/sign-in/email', { email, password: PASSWORD });
140+
const token = res.headers.get('set-auth-token');
141+
expect(token, `sign-in ${email} must mint a bearer or the legs below prove nothing`).toBeTruthy();
142+
return token!;
143+
};
144+
const adminBearer = await bearerFor('admin.11900@example.com');
145+
const memberBearer = await bearerFor('member.11900@example.com');
146+
147+
// The REAL route registration — raw mounts ahead of the catch-all.
148+
const app = new Hono();
149+
const ctx = mockCtx();
150+
const plugin = new AuthPlugin({ secret: SECRET });
151+
await plugin.init(ctx);
152+
(plugin as any).authManager = manager;
153+
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
154+
155+
const fire = (body: unknown, bearer?: string) =>
156+
app.request(`${ORIGIN}${BASE}${ROUTE}`, {
157+
method: 'POST',
158+
headers: {
159+
'content-type': 'application/json',
160+
origin: ORIGIN,
161+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
162+
},
163+
body: JSON.stringify(body),
164+
});
165+
166+
return { engine, fire, adminId, adminBearer, memberBearer };
167+
}
168+
169+
beforeEach(() => {
170+
vi.spyOn(console, 'warn').mockImplementation(() => {});
171+
vi.spyOn(console, 'error').mockImplementation(() => {});
172+
});
173+
afterEach(() => vi.restoreAllMocks());
174+
175+
// ───────────────────────────────────────────────────────────────────────────
176+
// THE FIX, WITH BOTH CONTRASTS — one staging, every caller
177+
// ───────────────────────────────────────────────────────────────────────────
178+
179+
describe('#11900 — /admin/has-permission answers the ADR-0068 platform admin', () => {
180+
it('admin true / same-query member false / ungranted-query admin false — both contrasts on one staging', async () => {
181+
const { engine, fire, adminId, adminBearer, memberBearer } = await stage();
182+
183+
// Control: the subject's standing is the GRANT, not the scalar. Without
184+
// this the true-leg below could be riding the vendor's own gate.
185+
const adminRow = (engine.tables.get('sys_user') ?? []).find(
186+
(r: any) => String(r.id) === adminId,
187+
);
188+
expect(
189+
adminRow?.role,
190+
'fixture control: the admin must NOT carry the legacy scalar — a scalared fixture is answered ' +
191+
'true by the UNSHADED vendor and measures nothing',
192+
).not.toBe('admin');
193+
194+
const granted = { permissions: { user: ['list'] } };
195+
196+
// ⭐ THE FIX. Before the mount this answered {"error":null,"success":false}
197+
// — the wrong-200 the card measured.
198+
const admin = await answerOf(await fire(granted, adminBearer));
199+
expect(admin.status, `admin granted-query: ${admin.text}`).toBe(200);
200+
expect(admin.json, 'the platform admin must get the answer real execution gives').toEqual({
201+
error: null,
202+
success: true,
203+
});
204+
205+
// ⛔ CALLER CONTRAST — the load-bearing negative. The member's own
206+
// negative ANSWER must stay exactly as it is: same envelope, same keys,
207+
// same verdict. A `true` here is the leak; a refusal here is a different
208+
// regression (the gate swallowing a self-scoped query).
209+
const member = await answerOf(await fire(granted, memberBearer));
210+
expect(member.status, `member granted-query: ${member.text}`).toBe(200);
211+
expect(member.json, 'the plain member’s negative answer must not move').toEqual({
212+
error: null,
213+
success: false,
214+
});
215+
216+
// ⛔ QUERY CONTRAST — the predicate decides WHO, the vendor's statements
217+
// still decide WHAT. `user: ['impersonate-admins']` is in the vendor's
218+
// statement vocabulary but NOT granted to its admin role; an unknown
219+
// resource is outside the vocabulary entirely. Both must stay `false` for
220+
// the admin, exactly as they would for a legacy-scalar admin — a mount
221+
// that echoes the predicate unconditionally fails here.
222+
for (const ungranted of [
223+
{ permissions: { user: ['impersonate-admins'] } },
224+
{ permissions: { 'not-a-vendor-resource': ['read'] } },
225+
{ permissions: {} }, // the vendor's empty query is a `false`, both roles
226+
]) {
227+
const a = await answerOf(await fire(ungranted, adminBearer));
228+
expect(a.status, `admin ungranted-query ${JSON.stringify(ungranted)}: ${a.text}`).toBe(200);
229+
expect(
230+
a.json,
231+
`an ungranted permission must still answer false to the admin — ${JSON.stringify(ungranted)}`,
232+
).toEqual({ error: null, success: false });
233+
}
234+
}, 120_000);
235+
236+
it('the delegated remainder is untouched: anonymous 401, vendor 400s in vendor order', async () => {
237+
const { fire, adminBearer } = await stage();
238+
239+
// Anonymous with an evaluable body → the vendor lane's enveloped 401
240+
// (#10349), delegated. The mount must never mint an answer for a caller
241+
// it did not resolve.
242+
const anon = await answerOf(await fire({ permissions: { user: ['list'] } }));
243+
expect(anon.status, `anonymous: ${anon.text}`).toBe(401);
244+
expect(anon.json?.error?.code, `anonymous code: ${anon.text}`).toBe('UNAUTHENTICATED');
245+
246+
// Bodies the vendor refuses to EVALUATE must keep the vendor's own 400 —
247+
// for the ADMIN caller. The shading must not put a confident answer where
248+
// the vendor's validation order puts a refusal (that would be a new
249+
// wrong-200), so every one of these delegates:
250+
const refused: Array<[string, unknown]> = [
251+
['no permission key at all', {}],
252+
['singular `permission` only (zod-valid, handler-refused)', { permission: { user: ['list'] } }],
253+
['both keys (the schema xor)', { permission: { user: ['list'] }, permissions: { user: ['list'] } }],
254+
['non-string action element', { permissions: { user: [1] } }],
255+
['permissions not a record', { permissions: 'user' }],
256+
['non-string role alongside a valid query', { role: 5, permissions: { user: ['list'] } }],
257+
];
258+
for (const [label, body] of refused) {
259+
const a = await answerOf(await fire(body, adminBearer));
260+
expect(a.status, `${label}: expected the vendor's own 400, got ${a.status} ${a.text}`).toBe(400);
261+
expect(a.json?.success, `${label}: a refused body must never read as an answer`).not.toBe(true);
262+
}
263+
}, 120_000);
264+
});
265+
266+
// ───────────────────────────────────────────────────────────────────────────
267+
// The acceptance mirror, both directions (see the module header for why a
268+
// looser OR stricter set than the vendor's is each its own wrong-200)
269+
// ───────────────────────────────────────────────────────────────────────────
270+
271+
describe('#11900 — readEvaluatedPermissionQuery mirrors the vendor-evaluated set', () => {
272+
it('accepts exactly the bodies the installed vendor handler evaluates', () => {
273+
// Evaluated by the vendor → returned for answering.
274+
expect(readEvaluatedPermissionQuery({ permissions: { user: ['list'] } })).toEqual({
275+
user: ['list'],
276+
});
277+
expect(
278+
readEvaluatedPermissionQuery({ userId: 42, role: 'user', permissions: { a: [] } }),
279+
'userId is dead on the wire and any JSON value coerces; a string role passes zod',
280+
).toEqual({ a: [] });
281+
expect(readEvaluatedPermissionQuery({ permissions: {} }), 'the empty query IS evaluated (to false)').toEqual({});
282+
283+
// Refused (or never evaluated) by the vendor → undefined → delegate.
284+
expect(readEvaluatedPermissionQuery(undefined)).toBeUndefined();
285+
expect(readEvaluatedPermissionQuery('permissions')).toBeUndefined();
286+
expect(readEvaluatedPermissionQuery({})).toBeUndefined();
287+
expect(readEvaluatedPermissionQuery({ permission: { user: ['list'] } }), 'singular-only dies in the handler').toBeUndefined();
288+
expect(
289+
readEvaluatedPermissionQuery({ permission: { user: ['list'] }, permissions: { user: ['list'] } }),
290+
'both keys fail the schema xor',
291+
).toBeUndefined();
292+
expect(readEvaluatedPermissionQuery({ permissions: { user: 'list' } })).toBeUndefined();
293+
expect(readEvaluatedPermissionQuery({ permissions: { user: [1] } })).toBeUndefined();
294+
expect(readEvaluatedPermissionQuery({ permissions: ['user'] })).toBeUndefined();
295+
expect(readEvaluatedPermissionQuery({ role: 5, permissions: { user: ['list'] } })).toBeUndefined();
296+
});
297+
});

0 commit comments

Comments
 (0)