From a03c076c0cb06c2b6bafba60943c9abaaf5b3386 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:46:36 +0000 Subject: [PATCH] fix(rest): carry the ADR-0090 D9/D10 principal taxonomy on the REST face (#6071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveAuthzContext` (@objectstack/core) was extracted so the two HTTP entry points could never drift on AUTHORIZATION. The step after it — assembling the ExecutionContext from that envelope — is still two hand-written copies, and they had drifted: the runtime/dispatcher copy sets `principalKind` (and `onBehalfOf` on the agent arm) per ADR-0090 D9/D10; `rest-server.ts` `computeExecCtx` set neither. Consumers are on the enforcement face (explain's posture floor, the security plugin's agent baseline, the perf-disclosure gate), so every judgment reading `principalKind` was silently never-true on the `os serve` / `dev` data and metadata routes. This carries the field on the REST assembly with the runtime side's semantics, restricted to the provenances this door accepts: a session- or API-key-backed principal is `human`. `agent` (and with it `onBehalfOf`, which only that arm sets) needs an OAuth access token naming an authorized client, honoured on the `/mcp` door alone; `guest` is likewise not representable — `computeExecCtx` returns undefined for an envelope without a userId, so anonymous REST callers keep getting no context at all and the 401 that follows. No consolidation of the two assemblies (out of scope), no narrowing of the field's type ('service' / 'system' are live values). Tests: a new end-to-end pin on the REST face — a real registered route through the real computeExecCtx -> resolveAuthzContext pipeline, asserting on the key set of the context that reaches the protocol (the issue's own dogfood instrumentation, moved into a test), plus the activated-branch pins. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW --- .changeset/rest-exec-ctx-principal-kind.md | 37 +++ .../src/rest-exec-ctx-principal-kind.test.ts | 220 ++++++++++++++++++ packages/rest/src/rest-server.ts | 32 +++ 3 files changed, 289 insertions(+) create mode 100644 .changeset/rest-exec-ctx-principal-kind.md create mode 100644 packages/rest/src/rest-exec-ctx-principal-kind.test.ts diff --git a/.changeset/rest-exec-ctx-principal-kind.md b/.changeset/rest-exec-ctx-principal-kind.md new file mode 100644 index 0000000000..54f400f555 --- /dev/null +++ b/.changeset/rest-exec-ctx-principal-kind.md @@ -0,0 +1,37 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): REST 面的执行上下文补齐 ADR-0090 D9/D10 的 principal 分类(#6071) + +`resolveAuthzContext`(`@objectstack/core`)被提取出来,正是为了让两个 HTTP 入口 +不再在**授权**上漂移。但它之后的一步 —— 把授权信封组装成 `ExecutionContext` —— +仍是两份手写副本,而两份的字段集已经不一致:runtime / dispatcher 那份 +(`packages/runtime/src/security/resolve-execution-context.ts`)按 ADR-0090 D9/D10 +设置 `principalKind`(必要时连同 `onBehalfOf`),`rest-server.ts` 的 `computeExecCtx` +两个都不设。 + +后果不在装饰面而在 enforcement 面:`plugin-security/explain-engine.ts` 的 +posture 下限、`security-plugin.ts` 的 agent 基线、`observability/perf-timing.ts` +的披露闸门都读 `principalKind`,于是同一个请求走 dispatcher 与走 REST 会拿到不同 +的上下文,读这个字段的判断在 `os serve` / `dev` 的数据与元数据路由上**从不成立**。 +问题由 #5859 实施时的 dogfood 全栈 boot 插桩测得:到达消费方的键集里 `__kernel` +在(自证是 rest-server 这条组装路径)、`principalKind` 不在。 + +本次改动只补这一个传输上缺的字段,口径与 runtime 侧完全一致: + +- 会话(cookie)或 API key 背书的主体 ⇒ `principalKind: 'human'` —— 与 runtime + 侧「an authenticated (API-key) request resolves as a human principal, never + guest」的钉子同一判定。 +- `'agent'` 与随之而来的 `onBehalfOf` **在本传输上不可表达**:它需要一个指明已授权 + 客户端的 OAuth access token,而该凭据只在 dispatcher 的 `/mcp` 门上被接受 + (`acceptOAuthAccessToken`),正是为了不让粗粒度的工具族 scope 溜进 REST。 +- `'guest'` 同样不可表达:`computeExecCtx` 在信封没有 `userId` 时就返回 + `undefined`,匿名 REST 调用者本来就拿不到任何上下文(随后被 `enforceAuth` 401)。 + **匿名面零变化** —— 不给匿名调用者凭空发一个 guest 上下文。 + +行为差量(逐条核过,无一条改变授权结果):`explain-engine.ts` 的 guest ⇒ `EXTERNAL` +与 `security-plugin.ts` 的 agent 分支在 REST 面仍不成立(前者的 `!context?.userId` +前肢本就恒真,后者读 `'agent'` 标签、且真正的兜底是委托 LINK);`perf-timing.ts` +只认 `'service'` / `'system'`,`'human'` 不开闸。唯一可观测的新增是 explain 输出里 +多回显一个 `principalKind: 'human'`(该字段在 explain schema 中本就是 optional)。 diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts new file mode 100644 index 0000000000..61fde0ee8d --- /dev/null +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6071 — the REST transport's `authorization envelope → ExecutionContext` +// assembly dropped the ADR-0090 D9/D10 principal taxonomy. +// +// `resolveAuthzContext` (@objectstack/core) was extracted so the two HTTP entry +// points could never drift on AUTHORIZATION — but the step AFTER it, assembling +// the ExecutionContext, is still two hand-written copies. The runtime / +// dispatcher copy (`packages/runtime/src/security/resolve-execution-context.ts`) +// set `principalKind` (+ `onBehalfOf` on the agent arm); the REST copy +// (`rest-server.ts` `computeExecCtx`) set neither, so every enforcement-side +// judgment that reads `principalKind` was silently NEVER-TRUE on the REST face +// (explain's guest⇒EXTERNAL floor, the security plugin's agent baseline, the +// perf-disclosure gate). +// +// The issue's own evidence was an instrumented dogfood boot printing the key +// set of the context that ARRIVED at a consumer plugin — `__kernel` present +// (proving the rest-server path), `principalKind` absent. These tests reproduce +// that instrumentation on the wire: a real registered route, the real +// `computeExecCtx` → `resolveAuthzContext` pipeline, and the captured context +// the protocol actually receives. + +import { describe, it, expect, vi } from 'vitest'; +import { hashApiKey } from '@objectstack/core'; +import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; +import { RestServer } from './rest-server'; + +const TASK = { + name: 'task', + label: '任务', + fields: { id: { type: 'text', label: 'ID' }, title: { type: 'text', label: '标题' } }, +}; + +const makeServer = () => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}); + +const FUTURE = '2999-01-01T00:00:00Z'; +/** The raw API key `member1` presents; `sys_api_key` stores only its hash. */ +const RAW_API_KEY = 'osk_6071_rest_face'; + +/** + * A fake data engine holding what a real deployment holds: one `sys_api_key` + * row for `member1`, a `member_default` grant for `member1` and an UNSCOPED + * `admin_full_access` grant for `admin1` (→ PLATFORM_ADMIN). Everything else + * resolves empty. + */ +const makeQl = () => ({ + find: async (object: string, opts: any) => { + const where = opts?.where ?? {}; + if (object === 'sys_api_key') { + const row = { id: 'k1', key: hashApiKey(RAW_API_KEY), revoked: false, user_id: 'member1', expires_at: FUTURE }; + return Object.entries(where).every(([k, v]) => (row as any)[k] === v) ? [row] : []; + } + if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + if (object === 'sys_user_permission_set') { + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + return []; + } + if (object === 'sys_permission_set') { + const ids: string[] = where.id?.$in ?? []; + return [ + ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ].filter(Boolean); + } + return []; + }, +}); + +/** A fake auth service whose session is keyed off the request's `cookie` header. */ +const makeAuth = () => ({ + api: { + getSession: async ({ headers }: { headers: any }) => { + const cookie = headers?.get?.('cookie'); + if (cookie === 'admin') return { user: { id: 'admin1' } }; + if (cookie === 'member') return { user: { id: 'member1' } }; + return undefined; + }, + }, +}); + +function makeRes() { + let status = 200; + const res: any = { + write: () => true, + end: () => {}, + header: () => res, + status: (code: number) => { status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return { res, getStatus: () => status, getJson: () => res._json }; +} + +/** + * Boot the REAL data route over a stub protocol. `findData` records the + * ExecutionContext the transport handed it — the instrumentation point of the + * issue, moved into a test. + */ +function boot() { + const seen: any[] = []; + const protocol: any = { + getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), + findData: vi.fn(async (args: any) => { seen.push(args.context); return { object: 'task', records: [] }; }), + }; + const rest = new RestServer( + makeServer() as any, + protocol as any, + {} as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + async () => makeAuth(), // authServiceProvider + async () => makeQl(), // objectQLProvider + ); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object'); + expect(route).toBeDefined(); + return { rest, route, seen, protocol }; +} + +/** Drive `GET /api/v1/data/task` with the given request headers. */ +async function request(headers: Record) { + const { route, seen, protocol } = boot(); + const out = makeRes(); + await route!.handler({ method: 'GET', params: { object: 'task' }, query: {}, headers } as any, out.res); + return { ctx: seen[0], out, protocol }; +} + +describe('#6071 — REST-face ExecutionContext carries the ADR-0090 D9/D10 principal taxonomy', () => { + it('a session-backed request reaches the data layer with principalKind:human', async () => { + const { ctx, out } = await request({ cookie: 'member' }); + + expect(out.getStatus()).toBe(200); + expect(ctx?.userId).toBe('member1'); + // The regression itself: the key was ABSENT from the key set the issue + // measured in a dogfood boot. Assert on the key set, not just the value, + // so re-dropping the field fails here even if some consumer defaults it. + expect(Object.keys(ctx)).toContain('principalKind'); + expect(ctx.principalKind).toBe('human'); + }); + + it('an API-key-backed request is a human principal too — the same verdict the runtime face pins', async () => { + // Runtime-side pin, verbatim in intent: "an authenticated (API-key) + // request resolves as a human principal, never guest" + // (packages/runtime/src/security/resolve-execution-context.test.ts). + // Same envelope, same provenance, same label — that is the drift closed. + const { ctx } = await request({ 'x-api-key': RAW_API_KEY }); + + expect(ctx?.userId).toBe('member1'); + expect(ctx.principalKind).toBe('human'); + expect(ctx.positions).not.toContain('guest'); + }); + + it('leaves onBehalfOf ABSENT — this transport has no delegation provenance', async () => { + // `onBehalfOf` is set ONLY by the runtime resolver's agent arm, which + // needs an OAuth access token naming an authorized client. That + // credential is honoured on the `/mcp` door alone + // (`acceptOAuthAccessToken`), so no REST request can produce a delegated + // principal. Absent here == absent for every human principal on the + // dispatcher face; it is parity, not a second gap. If REST ever accepts + // an OAuth client credential, this pin is the thing that must change. + const { ctx } = await request({ cookie: 'member' }); + + expect(ctx.onBehalfOf).toBeUndefined(); + expect(Object.keys(ctx)).not.toContain('onBehalfOf'); + expect(ctx.principalKind).not.toBe('agent'); + }); + + it('an anonymous request is UNCHANGED: no context at all, 401 — never a guest-labelled one', async () => { + // The guest arm of the runtime derivation is not representable here and + // is deliberately NOT reproduced: `computeExecCtx` returns undefined + // when the envelope carries no userId, so the anonymous REST face keeps + // its 401 (`enforceAuth`). Labelling anonymous callers `guest` here + // would hand them a context where they previously had none — a behaviour + // change well beyond D9/D10's declared intent. + const { ctx, out, protocol } = await request({}); + + expect(ctx).toBeUndefined(); + expect(out.getStatus()).toBe(401); + expect(protocol.findData).not.toHaveBeenCalled(); + }); + + it('the platform-admin principal is labelled human as well — the label is provenance, not privilege', async () => { + const { ctx } = await request({ cookie: 'admin' }); + + expect(ctx.principalKind).toBe('human'); + // Posture keeps saying what it always said; the new field adds no + // authority of its own. + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + }); +}); + +describe('#6071 — activated-branch pin: the perf-disclosure gate (perf-timing.ts:475)', () => { + // `isPerfDisclosurePrincipal` reads `principalKind` for 'service' / 'system' + // ONLY. The REST face produces neither, so labelling it 'human' cannot open + // the per-request `Server-Timing` disclosure — the gate keeps deciding on + // `isSystem` / `posture` exactly as before this change. + const resolveInGate = async (headers: Record) => { + const { route } = boot(); + const gate: PerfDisclosureGate = { allowed: false }; + const out = makeRes(); + await runWithPerfDisclosure(gate, () => + route!.handler({ method: 'GET', params: { object: 'task' }, query: {}, headers } as any, out.res), + ); + return gate; + }; + + it('stays CLOSED for the newly-labelled human member', async () => { + expect((await resolveInGate({ cookie: 'member' })).allowed).toBe(false); + }); + + it('still OPENS for a platform admin (posture, unchanged by the new label)', async () => { + const gate = await resolveInGate({ cookie: 'admin' }); + expect(gate.allowed).toBe(true); + expect(gate.privileged).toBe(true); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 90b76329dd..095868c815 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2404,6 +2404,38 @@ export class RestServer { // enforcement side reads the SAME value the resolver computed, // instead of dropping it here (the boundary this issue closes). ...(authz.posture ? { posture: authz.posture } : {}), + // [ADR-0090 D9/D10 / #6071] Principal taxonomy, resolved by the + // SAME rule the runtime / MCP entry applies + // (`packages/runtime/src/security/resolve-execution-context.ts`) + // so the two transports can no longer disagree about WHO is + // asking. Enforcement reads this field + // (`plugin-security/explain-engine.ts` derivePosture, + // `security-plugin.ts` agent baseline, `perf-timing.ts` + // disclosure gate), and until now it arrived on the dispatcher + // face only — every such judgment was silently never-true on + // REST. + // + // `'human'` is the ONLY kind this transport can produce, and + // that is the runtime rule restricted to the provenances this + // door accepts, not a second derivation: + // - `agent` (+ the `onBehalfOf` delegation link, which ONLY + // the agent arm sets) requires an OAuth access token naming + // an authorized client. This transport never accepts one: + // OAuth bearers are honoured on the `/mcp` door alone + // (`acceptOAuthAccessToken`, set solely by the dispatcher's + // `/mcp` path match) precisely so coarse tool-family scopes + // cannot ride onto REST. So `onBehalfOf` is not + // representable here — same as every human principal on the + // dispatcher face, which also leaves it undefined. + // - `guest` is not representable either: this method returned + // `undefined` above when the envelope carried no `userId`, + // so an anonymous REST caller gets NO context at all (and + // `enforceAuth` 401s it). Anonymous REST is unchanged. + // - A session-backed OR API-key-backed principal is `human` on + // both faces (pinned on the runtime side by + // resolve-execution-context.test.ts, "an authenticated + // (API-key) request resolves as a human principal"). + principalKind: 'human', isSystem: false, org_user_ids: authz.org_user_ids, // [ADR-0105 D2] The caller's org access set — the `group`