diff --git a/.changeset/current-user-endpoints-kernel-resolver.md b/.changeset/current-user-endpoints-kernel-resolver.md new file mode 100644 index 0000000000..f57cb82e3a --- /dev/null +++ b/.changeset/current-user-endpoints-kernel-resolver.md @@ -0,0 +1,60 @@ +--- +"@objectstack/plugin-hono-server": minor +--- + +fix(plugin-hono-server): the current-user endpoints answer from the kernel that OWNS the request (cloud#927) + +`/api/v1/auth/me/permissions`, `/auth/me/localization` and `/me/apps` resolved +their answer from the service locator captured at REGISTRATION time. On a +single-environment host that is the only kernel, so it is right. On a +**multi-tenant** host it is the routing shell — and identity is not there. +cloud's `ArtifactKernelFactory` mounts `AuthPlugin` per environment, and its host +kernel deliberately has none ("AuthPlugin is intentionally NOT injected on the +host"), so `getService('auth')` threw, the session resolver fell to its catch, and +every authenticated tenant caller got `{authenticated:false}`. + +That is worse than an error: objectui's `MePermissionsProvider` reads +`authenticated:false` as ANONYMOUS and keeps its permissive default +(`return data.authenticated !== true`), because a guest surface has no resolvable +permissions by design. So the console's FLS / `apiOperations` hints were +systematically wrong — not a bypass (the server still enforces per request), but +exactly the client/server divergence `foldWildcardSuperUser` and +`clampManagedObjectWrites` exist to close, one layer up. + +These endpoints now consult the host's ADR-0006 **`kernel-resolver`** seam per +request — the same seam the runtime dispatcher has used since Phase 5, so +multi-tenant routing has one strategy rather than two: + +- **No `kernel-resolver` registered** → unchanged. Single-environment hosts, + `os serve`, and the QA conformance host see no difference. +- **A kernel** → that kernel's `auth` / `objectql` / `metadata` / + `security.permissions` answer. +- **`undefined`** → the registration-time locator, which is the seam's contract + for an unscoped / control-plane request. +- **A throw** → no answer at all: the thrown status when it carries one (cloud's + `KernelWarmingError` is 503 + `Retry-After`), else 503 + `environment_unavailable`. Falling back to the default kernel would hand back a + confidently-wrong `{authenticated:false}` that the client fails OPEN on. + +The seam is read **lazily, per request**, never captured at registration — a host +may register these routes before `kernel.bootstrap()` (to outrank an +`/api/v1/auth/*` wildcard), which is before the plugin that registers the +resolver has run its `init()`. + +**FROM → TO for host adapters.** `CurrentUserEndpointsContext` gains an optional +`getKernel(): unknown`, the `defaultKernel` argument the seam takes. A +`PluginContext` already satisfies it, so hosts that mount `HonoServerPlugin` need +no change. A host passing a hand-rolled locator to +`registerCurrentUserEndpoints` should add it: + +```diff + registerCurrentUserEndpoints({ + rawApp: httpServer.getRawApp(), +- ctx: { getService: (n) => kernel.getService(n) }, ++ ctx: { getService: (n) => kernel.getService(n), getKernel: () => kernel }, + }); +``` + +Without it a multi-tenant host cannot be asked which kernel owns the request and +keeps the old provenance — a silent downgrade, so it is worth adding even where +the host is single-environment today. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-multi-tenant.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-multi-tenant.test.ts new file mode 100644 index 0000000000..681ce4f0af --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-multi-tenant.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// cloud#927 — WHICH kernel answers the current-user endpoints. +// +// These three resolved their answer from the locator captured at REGISTRATION +// time. On a single-environment host that is the only kernel and therefore +// right. On a multi-tenant host it is the routing shell: cloud's +// `ArtifactKernelFactory` mounts `AuthPlugin` per ENVIRONMENT, and its host +// kernel deliberately has no `auth` at all ("AuthPlugin is intentionally NOT +// injected on the host"). So `ctx.getService('auth')` threw there, `resolveCtx` +// fell to its catch, and every authenticated tenant caller got +// `{authenticated:false}` — which objectui's `MePermissionsProvider` reads as +// ANONYMOUS and fails OPEN on (`return data.authenticated !== true`). The +// server still enforces per request, so it was not a bypass; the client's FLS +// hints were just systematically wrong. +// +// The framework already declares the seam for this: `kernel-resolver` +// (ADR-0006 Phase 5), which the dispatcher has consulted since. These endpoints +// not consulting it was the defect. + +import { describe, it, expect, vi } from 'vitest'; +import { Hono } from 'hono'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; +const ME_LOCALIZATION = '/api/v1/auth/me/localization'; +const ME_APPS = '/api/v1/me/apps'; + +/** A kernel stub whose `getService` throws for anything not seeded (like the real one). */ +function kernelWith(services: Record) { + return { + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }; +} + +/** An `auth` service that resolves one fixed user. */ +const authFor = (userId: string, extra: Record = {}) => ({ + api: { getSession: vi.fn(async () => ({ user: { id: userId }, session: extra })) }, +}); + +/** + * Mount on a bare Hono app over `hostServices`. `resolveKernel` is installed as + * the host's `kernel-resolver` service — after registration, so the tests also + * pin that the seam is read LAZILY (cloud registers these routes before + * `kernel.bootstrap()`, i.e. before the plugin that registers the resolver has + * run its `init()`; capturing the service at registration time would find + * nothing and silently keep the old behaviour). + */ +function mount(hostServices: Record, resolveKernel?: (...a: any[]) => any) { + const app = new Hono(); + const services: Record = { ...hostServices }; + const kernel = kernelWith(services); + const ctx = { + logger: { debug() {}, warn() {} }, + getService: (name: string): T => kernel.getService(name), + getKernel: () => kernel, + }; + + registerCurrentUserEndpoints({ rawApp: app, ctx }); + + // Registered AFTER — the lazy read is what makes this visible. + if (resolveKernel) services['kernel-resolver'] = { resolveKernel }; + + return { app, kernel, services }; +} + +const get = (app: any, path: string) => + app.request(`http://tenant.example.com${path}`, { headers: { host: 'tenant.example.com' } }); + +describe('the answer comes from the kernel that OWNS the request (cloud#927)', () => { + it('a resolved environment kernel answers, not the routing shell', async () => { + // The shell has no `auth` — exactly cloud's objectos host. + const envKernel = kernelWith({ auth: authFor('usr_env') }); + const { app } = mount({}, async () => envKernel); + + const res = await get(app, ME_PERMISSIONS); + + expect(res.status).toBe(200); + // Before this, the shell's missing `auth` made this `{authenticated:false}` + // for every real caller — the fail-open the client then inherited. + expect(await res.json()).toMatchObject({ authenticated: true, userId: 'usr_env' }); + }); + + it('prefers the environment kernel over a host that DOES have auth', async () => { + // Pins that resolution wins rather than merely filling a gap: a host with + // its own identity must not answer for a tenant request. + const envKernel = kernelWith({ auth: authFor('usr_env') }); + const { app } = mount({ auth: authFor('usr_host') }, async () => envKernel); + + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_env' }); + }); + + it('routes /auth/me/localization and /me/apps through the same resolution', async () => { + const envKernel = kernelWith({ + auth: authFor('usr_env'), + objectql: { _registry: { getAllApps: () => [{ name: 'env_app' }] } }, + }); + const { app } = mount({}, async () => envKernel); + + expect(await (await get(app, ME_LOCALIZATION)).json()).toMatchObject({ authenticated: true }); + expect(await (await get(app, ME_APPS)).json()).toEqual({ apps: [{ name: 'env_app' }] }); + }); + + it('hands the resolver the prefix-stripped routePath the dispatcher would', async () => { + // cloud's resolver applies its own path policy to `routePath` (control-plane + // prefixes skip environment resolution), so the shape has to match. + const seen: any[] = []; + const envKernel = kernelWith({ auth: authFor('usr_env') }); + const { app, kernel } = mount({}, async (context: any, defaultKernel: any) => { + seen.push({ routePath: context.routePath, isDefault: defaultKernel === kernel, headers: context.request?.headers }); + return envKernel; + }); + + await get(app, ME_PERMISSIONS); + await get(app, ME_APPS); + + expect(seen.map((s) => s.routePath)).toEqual(['/auth/me/permissions', '/me/apps']); + // The seam's second argument is the host kernel, as `KernelResolver` declares. + expect(seen.every((s) => s.isDefault)).toBe(true); + // Cloud's resolver reads `host` / `x-environment-id` off these. + expect(seen[0].headers).toBeInstanceOf(Headers); + }); +}); + +describe('the default kernel still answers where the seam says it should', () => { + it('no kernel-resolver at all — single-environment hosts are untouched', async () => { + const { app } = mount({ auth: authFor('usr_host') }); + + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); + }); + + it('resolver returns undefined — the contract for an unscoped request', async () => { + const { app } = mount({ auth: authFor('usr_host') }, async () => undefined); + + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); + }); + + it('resolver hands back the default kernel itself', async () => { + const { app, kernel } = mount({ auth: authFor('usr_host') }, async (_c: any, d: any) => d ?? kernel); + + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); + }); + + it('a locator with no getKernel cannot be asked, so it answers itself', async () => { + // Documented downgrade, not a neutral choice — a multi-tenant host that + // omits `getKernel` gets the old (wrong) provenance back. + const app = new Hono(); + const kernel = kernelWith({ auth: authFor('usr_host'), 'kernel-resolver': { resolveKernel: vi.fn() } }); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { logger: { debug() {}, warn() {} }, getService: (n) => kernel.getService(n) }, + }); + + expect(await (await get(app, ME_PERMISSIONS)).json()).toMatchObject({ userId: 'usr_host' }); + }); +}); + +describe('a kernel that cannot be reached is a retryable error, never a fake anonymous', () => { + it('surfaces a warming 503 with Retry-After', async () => { + // cloud's `KernelWarmingError` shape: a cold environment kernel past the + // wait budget. AuthProxyPlugin already answers 503 + Retry-After for it. + // Only `statusCode` + `retryAfterSeconds` are load-bearing — its `code` is + // read for the log line alone, so the fixture omits it rather than carry a + // lowercase literal in a `code:` position (ADR-0112 D1). + const warming = Object.assign(new Error('kernel warming'), { + statusCode: 503, retryAfterSeconds: 4, + }); + const { app } = mount({}, async () => { throw warming; }); + + const res = await get(app, ME_PERMISSIONS); + + expect(res.status).toBe(503); + expect(res.headers.get('Retry-After')).toBe('4'); + expect(await res.json()).toMatchObject({ error: 'environment_unavailable' }); + }); + + it('a resolver failure with no status is a 503, not a fall-back to the shell', async () => { + // Falling back would answer `{authenticated:false}` off the shell, which the + // client reads as anonymous and fails OPEN on — worse than a retryable error. + const { app } = mount({ auth: authFor('usr_host') }, async () => { throw new Error('registry down'); }); + + const res = await get(app, ME_PERMISSIONS); + + expect(res.status).toBe(503); + expect(await res.json()).not.toMatchObject({ authenticated: false }); + }); + + it('applies to all three endpoints', async () => { + const { app } = mount({}, async () => { throw new Error('registry down'); }); + + for (const path of [ME_PERMISSIONS, ME_LOCALIZATION, ME_APPS]) { + expect((await get(app, path)).status, path).toBe(503); + } + }); + + it('passes a 4xx from the resolver through unchanged', async () => { + const denied = Object.assign(new Error('nope'), { statusCode: 403 }); + const { app } = mount({}, async () => { throw denied; }); + + expect((await get(app, ME_PERMISSIONS)).status).toBe(403); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 0df07cb71b..cd2242c535 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -65,6 +65,30 @@ export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1'; export interface CurrentUserEndpointsContext { getService(name: string): T | undefined; logger?: Partial>; + /** + * The kernel this locator reads from. Optional, and used for ONE thing: it + * is the `defaultKernel` argument the host's `kernel-resolver` seam takes + * (see {@link resolveRequestContext}). `PluginContext.getKernel` satisfies + * it; a host adapter should supply it too. Without it a multi-tenant host + * cannot be asked which kernel owns the request, and these endpoints answer + * from `getService` — which on such a host is the wrong kernel (#927 is the + * cloud-side record of that failure), so omitting it is a downgrade, not a + * neutral choice. + */ + getKernel?(): unknown; +} + +/** + * Minimal shape of the host's ADR-0006 kernel-resolution seam, as registered + * under the `kernel-resolver` service name. Structurally the framework's + * `KernelResolver` (`@objectstack/runtime`), declared here rather than imported + * so this package keeps no runtime dependency on the dispatcher. + */ +export interface KernelResolverLike { + resolveKernel( + context: { request: { headers: unknown }; routePath?: string; environmentId?: string }, + defaultKernel: unknown, + ): Promise | unknown | undefined; } /** Options for {@link registerCurrentUserEndpoints}. */ @@ -81,6 +105,93 @@ export interface RegisterCurrentUserEndpointsOptions { prefix?: string; } +/** Body returned when the request's environment kernel cannot be reached. */ +const ENVIRONMENT_UNAVAILABLE = { + error: 'environment_unavailable', + message: 'The environment serving this request is not available yet. Retry shortly.', +} as const; + +/** A locator over an arbitrary kernel, for the per-request resolution below. */ +function contextForKernel(kernel: any, from: CurrentUserEndpointsContext): CurrentUserEndpointsContext { + return { + // Sync `getService`, like every other read on this surface. Per-environment + // kernels register `auth` / `objectql` / `metadata` / + // `security.permissions` as INSTANCES (their plugins register them in + // `init()`), so they are in the service map by the time a request arrives. + getService: (name: string): T | undefined => kernel?.getService?.(name) as T | undefined, + logger: from.logger, + getKernel: () => kernel, + }; +} + +/** + * Which kernel's services answer THIS request. + * + * Single-environment hosts register no `kernel-resolver` and are unchanged: the + * registration-time locator is the answer. On a MULTI-TENANT host the seam is + * the whole point — identity lives on the per-environment kernel (cloud's + * `ArtifactKernelFactory` mounts `AuthPlugin` per environment; its host kernel is + * a routing shell with no `auth` at all), so resolving against the host kernel + * reports `{authenticated:false}` for every real caller and the console's + * permission layer silently falls open. The dispatcher has consulted this seam + * since ADR-0006 Phase 5; these three endpoints not consulting it was the defect + * (#927). + * + * Resolution is LAZY — the service is read per request, never captured at + * registration — because a host may register these routes before + * `kernel.bootstrap()` (to outrank an auth wildcard), which is before the plugin + * that registers the resolver has run its `init()`. + * + * Four outcomes, and the two failure ones are deliberate: + * - no resolver (or no `getKernel`) → the registration-time locator. + * - a kernel → that kernel's services. THE fix. + * - `undefined` → the registration-time locator. That is the seam's contract + * for "unscoped / control-plane / single-environment request", and on such a + * host the default kernel really is the right answer. + * - a throw → **no answer at all**, surfaced as the thrown status when it + * carries one (cloud's `KernelWarmingError` is a 503 + `Retry-After`) else + * 503. Falling back to the default kernel here would hand back a + * confidently-wrong `{authenticated:false}`, which the client reads as + * "anonymous" and fails OPEN on — strictly worse than a retryable error. + */ +async function resolveRequestContext( + c: any, + ctx: CurrentUserEndpointsContext, + prefix: string, +): Promise<{ ctx: CurrentUserEndpointsContext } | { response: Response }> { + const resolver = (() => { + try { return ctx.getService('kernel-resolver'); } catch { return undefined; } + })(); + if (typeof resolver?.resolveKernel !== 'function' || typeof ctx.getKernel !== 'function') { + return { ctx }; + } + const defaultKernel = ctx.getKernel(); + // `routePath` is the API-prefix-stripped path, matching what the dispatcher + // hands the resolver — cloud's resolver applies its own path policy to it + // (control-plane prefixes skip environment resolution). + const path: string = c.req.path ?? ''; + const routePath = path.startsWith(prefix) ? path.slice(prefix.length) || '/' : path; + try { + const resolved = await resolver.resolveKernel( + { request: { headers: c.req.raw.headers }, routePath }, + defaultKernel, + ); + if (!resolved || resolved === defaultKernel) return { ctx }; + return { ctx: contextForKernel(resolved, ctx) }; + } catch (err: any) { + const status = typeof err?.statusCode === 'number' && err.statusCode >= 400 && err.statusCode <= 599 + ? err.statusCode + : 503; + ctx.logger?.warn?.('[hono] current-user endpoint: environment resolution failed', { + err: err?.message, code: err?.code, status, routePath, + }); + if (typeof err?.retryAfterSeconds === 'number' && err.retryAfterSeconds > 0) { + try { c.header('Retry-After', String(Math.ceil(err.retryAfterSeconds))); } catch { /* best effort */ } + } + return { response: c.json(ENVIRONMENT_UNAVAILABLE, status) }; + } +} + /** The three route paths this module owns, under `prefix`. */ export function currentUserRoutePaths(prefix: string = DEFAULT_CURRENT_USER_PREFIX): string[] { return [ @@ -520,7 +631,28 @@ export function registerCurrentUserEndpoints( ctx.logger?.debug?.('Current-user endpoints already registered — skipping', { prefix }); return false; } - const resolveCtx = makeExecutionContextResolver(ctx); + /** + * Wrap a handler so it runs against the kernel that OWNS the request. + * + * The handler's `ctx` parameter deliberately shadows the registration-time + * locator: every `ctx.getService(...)` in the bodies below then reads the + * per-request kernel on a multi-tenant host and the same kernel as before on + * a single-environment one, with no per-read plumbing to keep in sync. Same + * for `resolveCtx` — the caller's identity must be resolved from the kernel + * that owns the session, not the routing shell in front of it. + */ + const withRequestContext = ( + handler: ( + c: any, + ctx: CurrentUserEndpointsContext, + resolveCtx: ReturnType, + ) => Promise, + ) => async (c: any): Promise => { + const resolution = await resolveRequestContext(c, ctx, prefix); + if ('response' in resolution) return resolution.response; + return handler(c, resolution.ctx, makeExecutionContextResolver(resolution.ctx)); + }; + // Effective permissions for the current user — single aggregation // endpoint that resolves session → roles → permission sets → merged // field/object permissions. Frontend Field-Level Security (FLS) @@ -538,7 +670,7 @@ export function registerCurrentUserEndpoints( // // Returns `{authenticated:false}` (200) when no session is // present, so the frontend can distinguish anon from error. - rawApp.get(`${prefix}/auth/me/permissions`, async (c: any) => { + rawApp.get(`${prefix}/auth/me/permissions`, withRequestContext(async (c, ctx, resolveCtx) => { const execCtx = await resolveCtx(c); if (!execCtx?.userId) { return c.json({ authenticated: false }); @@ -738,7 +870,7 @@ export function registerCurrentUserEndpoints( ctx.logger?.warn?.('[hono] /auth/me/permissions failed', { err: err?.message }); return c.json({ authenticated: true, userId: execCtx.userId, objects: {}, fields: {} }); } - }); + })); // GET /me/localization — the resolved regional defaults (currency / // locale / timezone) for the current request's tenant, exposed to EVERY @@ -746,7 +878,7 @@ export function registerCurrentUserEndpoints( // `setup.access`, but the resolved defaults are needed by every renderer // to format currency/dates/numbers — so they ride on the request // ExecutionContext (ADR-0053) and are surfaced here without that gate. - rawApp.get(`${prefix}/auth/me/localization`, async (c: any) => { + rawApp.get(`${prefix}/auth/me/localization`, withRequestContext(async (c, ctx, resolveCtx) => { const execCtx = await resolveCtx(c); if (!execCtx?.userId) { return c.json({ authenticated: false }); @@ -757,7 +889,7 @@ export function registerCurrentUserEndpoints( locale: execCtx.locale ?? null, timezone: execCtx.timezone ?? null, }); - }); + })); // GET /me/apps — list apps the current user is allowed to enter. // Apps live in the ENGINE REGISTRY (runtime AppPlugin registerApp()), @@ -771,7 +903,7 @@ export function registerCurrentUserEndpoints( // 2. ctx.tabPermissions[app.name] !== 'hidden' // Anonymous users get an empty array. When SecurityPlugin is absent // we fail-open and return every app (matches server behaviour). - rawApp.get(`${prefix}/me/apps`, async (c: any) => { + rawApp.get(`${prefix}/me/apps`, withRequestContext(async (c, ctx, resolveCtx) => { const execCtx = await resolveCtx(c); if (!execCtx?.userId) return c.json({ apps: [] }); try { @@ -870,7 +1002,7 @@ export function registerCurrentUserEndpoints( ctx.logger?.warn?.('[hono] /me/apps failed', { err: err?.message }); return c.json({ apps: [] }); } - }); + })); ctx.logger?.debug?.('Registered current-user endpoints', { prefix }); return true; }