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
37 changes: 37 additions & 0 deletions .changeset/rest-exec-ctx-principal-kind.md
Original file line numberDiff line numberDiff line change
@@ -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)。
220 changes: 220 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, string>) {
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<string, string>) => {
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);
});
});
32 changes: 32 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Loading