Skip to content

Commit 968d645

Browse files
test: pin that a guest request reaches create hooks as a named caller (#30179)
## 维护者速读 - 改了什么:只加测试,三处钉住同一条规则——经访客入口进来的请求,在创建钩子看来始终是"有调用者"的请求,不会被当成内核内部的无上下文调用。 - 为什么改:这条规则此前只靠阅读成立,没有任何测试守住;访客信封里那个让引擎生成会话的字段一旦在重构中丢掉,不会有测试变红。 - 风险与回滚:零运行时改动(三个 `*.test.ts`,发布产物字节不变);回滚即 revert 本 PR。 - 先看:`packages/objectql/src/engine-execution-plumbing.session-locale.test.ts`(会话那一跳的钉子)、`packages/services/service-storage/src/attachment-access-hooks.test.ts`(整机引擎创建被拒)、`packages/core/src/security/assemble-execution-context.test.ts`(信封端)。 - 两分钟验证:`pnpm --filter @objectstack/service-storage exec vitest run src/attachment-access-hooks.test.ts -t "guest request"`,预期 2 passed。 - 需要你做的:无,席位落地。 ## What changes Test-only. One rule, pinned at three layers: **a request served through the guest entry reaches create hooks as a named caller, so it is never read as a context-less (bare-kernel) engine call.** - `@objectstack/core` — the guest envelope keeps the `guest` position under every input a face can pass for an anonymous request (no tenant / no grants, and guest-anchor grants in an organization). - `@objectstack/objectql` — `buildSession` of the envelope the real `assembleExecutionContextOrGuest` produces is defined and names no user (and is not the system arm); a control pins that an envelope naming nothing the session copies yields no session. - `@objectstack/service-storage` — through the wired engine (real `ObjectQL` + the installed attachment hooks), a create of `sys_attachment` under the real guest envelope is refused `403 RECORD_NOT_ACCESSIBLE` and writes no row; control: the same create by a signed-in caller resolves and stamps that caller as the uploader. New pins are top-level `describe` blocks in each component's existing test file. No product code changes, so `skip-changeset`: none of the three packages' `files[]` (`dist`, `README.md`, `CHANGELOG.md`) changes — the new test titles have zero hits in the three `dist/` trees while `assembleExecutionContextOrGuest` (positive control) hits `packages/core/dist/index.js`. ## Tests All on head `36bffe5b4c`. <details><summary>Changed files and package suites</summary> - Changed files, one worker each: core `assemble-execution-context.test.ts` 212/212, objectql `engine-execution-plumbing.session-locale.test.ts` 5/5, service-storage `attachment-access-hooks.test.ts` 153/153; each new test confirmed by name under `--reporter=verbose`. - Package suites under `os-verify-lock.sh`: core 133 files / 2974 tests passed; service-storage 71 / 1329 passed; objectql 548 / 10846 passed; each exit 0. - `typecheck` for core, objectql, service-storage: exit 0 (each package's `check:test-typecheck` OK). </details> <details><summary>One-shot ablation: drop the guest position from the guest envelope</summary> Mutation via `scripts/ablation-replace.mjs` on `packages/core/src/security/assemble-execution-context.ts`: the guest arm of `positions` replaced with an expression that evaluates to undefined (anchor 1 to 0, blob `442040f156df` to `0039cf95db86`). core rebuilt under the lock; `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs`. - core (source): exit 1, both new pins red, alongside the existing frozen-transcription parity pins. - service-storage (core aliased to source): exit 1, exactly the new refusal pin red; its control stayed green. - objectql (core through `dist/`): exit 1, exactly the new session pin red (`expected undefined to be defined`); its control stayed green. Restore: blob equals HEAD, `git diff HEAD` empty; core rebuilt, `--absent` preflight clean over 14 built files, objectql file green again (5/5). </details> ## Acceptance notes - `buildSession` treats an empty `positions: []` as present (its presence test is `!== undefined`), so the identification hinges on the key being emitted at all, not on its contents; the pins assert the `guest` value, which is the declared vocabulary. - The existing core parity pin (`toEqual` on the guest envelope) already went red under the ablation; the new pin names the rule so the reason survives a future edit of that frozen transcription. ## Verification <details><summary>Gate derivation and reconciliation</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 57 commands for the 3 changed paths. All 57 run, exit codes captured before any pipe; `--ran` reconciliation: "57 derived famil(ies) accounted for — 55 run, 2 NOT-MEASURED". - 55 exit 0. - NOT MEASURED (exit 3, PREREQUISITE NOT MET): `pnpm check:dual-build-cjs-loads` (needs every package's `dist/`) and `pnpm check:i18n` (needs the CLI build closure). Neither reads test files; CI's `Lint & Repo Gates` runs both on a full build. - Repo-wide scans and `test:repo` are left to CI. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fec1f58 commit 968d645

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

‎packages/core/src/security/assemble-execution-context.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,41 @@ describe('ruling 6355206139 — the resolver’s audience stamp rides the envelo
591591
expect(Object.keys(assemble({ ...ANONYMOUS, audience: 'external' }))).not.toContain('audience');
592592
});
593593
});
594+
595+
// The guest envelope is the ONLY thing that tells a downstream create hook an
596+
// anonymous request is a caller at all. Create hooks such as `sys_attachment`'s
597+
// and `sys_comment`'s keep a bare-kernel carve-out — no hook session means an
598+
// internal, context-less engine call, skipped before any user check — and the
599+
// engine builds that session from the envelope's caller fields
600+
// (`buildSession`, objectql `engine-execution-plumbing.ts`), which does not
601+
// read `principalKind`. So the anonymous principal must stay identified by a
602+
// field that survives that hop, whatever else the face resolved or withheld.
603+
// objectql's own suite pins the hop itself; this pin holds the envelope end.
604+
describe('a guest request is a named caller, never an empty context', () => {
605+
const GUEST_SHAPES: Array<[string, ResolvedAuthzContext]> = [
606+
// The minimum: no session, no key, no guest anchor grants — nothing but
607+
// the fact that someone asked.
608+
['no tenant, no grants, no token', ANONYMOUS],
609+
// A face that serves the guest resolves the anchor's grants in the
610+
// organization ADR-0138 D4 derives; the identification must not depend on it.
611+
['guest anchor grants in an organization', baseAuthz({ tenantId: 'org1', permissions: ['task.read'] })],
612+
];
613+
614+
for (const [name, authz] of GUEST_SHAPES) {
615+
it(`an anonymous request keeps the guest position on its envelope (${name}), so create hooks see a caller`, () => {
616+
const ctx = assembleExecutionContextOrGuest({
617+
authz,
618+
oauth: undefined,
619+
// Everything a face could pass for an anonymous request, so a later
620+
// change that strips more of it cannot strip the identification too.
621+
localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' },
622+
requestLocale: 'en-US',
623+
accessToken: undefined,
624+
authGate: undefined,
625+
});
626+
expect(ctx.userId).toBeUndefined();
627+
expect(ctx.isSystem).toBe(false);
628+
expect(ctx.positions).toEqual(['guest']);
629+
});
630+
}
631+
});

‎packages/objectql/src/engine-execution-plumbing.session-locale.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import { describe, it, expect } from 'vitest';
11+
import { assembleExecutionContextOrGuest } from '@objectstack/core';
1112
import { buildSession } from './engine-execution-plumbing.js';
1213

1314
describe('[#26170] buildSession carries the caller\'s locale', () => {
@@ -25,3 +26,46 @@ describe('[#26170] buildSession carries the caller\'s locale', () => {
2526
}
2627
});
2728
});
29+
30+
// Create hooks with a bare-kernel carve-out (`sys_attachment`'s and
31+
// `sys_comment`'s) read "no hook session" as an internal, context-less engine
32+
// call and skip their user checks for it. That reading is sound only while
33+
// every request door hands the engine a context this function turns into a
34+
// session — the guest door included, whose envelope names no user. Built
35+
// through the REAL guest entry (`@objectstack/core`), not a literal, so a
36+
// change to what that entry emits is measured here rather than assumed.
37+
describe('a guest request reaches create hooks with a session, so an anonymous create is never treated as a bare-kernel call', () => {
38+
// The anonymous resolver's own answer for a request with no credential:
39+
// the initial context `resolveAuthzContext` returns before any principal
40+
// is found (`packages/core/src/security/resolve-authz-context.ts`).
41+
const anonymousAuthz = () => ({
42+
positions: [] as string[],
43+
permissions: [] as string[],
44+
directPermissions: [] as string[],
45+
systemPermissions: [] as string[],
46+
org_user_ids: [] as string[],
47+
accessible_org_ids: [] as string[],
48+
});
49+
50+
it('the guest envelope with no tenant, no grants and no token still yields a session that names no user', () => {
51+
const guest = assembleExecutionContextOrGuest({
52+
authz: anonymousAuthz(),
53+
oauth: undefined,
54+
localization: undefined,
55+
requestLocale: undefined,
56+
accessToken: undefined,
57+
authGate: undefined,
58+
});
59+
const session = buildSession(guest);
60+
expect(session).toBeDefined();
61+
expect(session?.userId).toBeUndefined();
62+
// Not the system arm either: the hooks test `isSystem` before the user.
63+
expect(session).not.toHaveProperty('isSystem');
64+
});
65+
66+
it('CONTROL: an envelope that names nothing the session copies yields no session — the bare-kernel reading', () => {
67+
// The carve-out's own premise, so the pin above cannot pass on a
68+
// `buildSession` that answers a session for every input.
69+
expect(buildSession({ principalKind: 'guest' } as any)).toBeUndefined();
70+
});
71+
});

‎packages/services/service-storage/src/attachment-access-hooks.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi } from 'vitest';
4+
import { assembleExecutionContextOrGuest } from '@objectstack/core';
45
import { ObjectQL, assertEngineFindOnePredicate } from '@objectstack/objectql';
56
import { renderOperationMessage } from '@objectstack/spec/system';
67
import type { ControlledByParentWriteOutcome, ExplainAccessRequest } from '@objectstack/spec/contracts';
@@ -2015,3 +2016,48 @@ describe('attachment access — the file a row names must be one the caller may
20152016
expect(authorizeFileRead).not.toHaveBeenCalled();
20162017
});
20172018
});
2019+
2020+
// ─────────────────────────────────────────────────────────────────────────
2021+
// The guest door through the WIRED engine. The create gate skips a call that
2022+
// reaches it with no hook session (an internal, context-less engine call);
2023+
// a request that came through the guest door must never read as one. The
2024+
// envelope is the REAL guest entry's (`@objectstack/core`), handed to the
2025+
// REAL engine, so both the envelope and the engine's session hop are the
2026+
// production ones — the same rig as the unscoped-write blocks above, with
2027+
// the same rebuild-before-trusting note.
2028+
// ─────────────────────────────────────────────────────────────────────────
2029+
2030+
describe('a guest request through the wired engine is refused at create, never treated as a bare-kernel call', () => {
2031+
const guestContext = () =>
2032+
assembleExecutionContextOrGuest({
2033+
// The anonymous resolver's answer for a request with no credential.
2034+
authz: {
2035+
positions: [],
2036+
permissions: [],
2037+
directPermissions: [],
2038+
systemPermissions: [],
2039+
org_user_ids: [],
2040+
accessible_org_ids: [],
2041+
},
2042+
oauth: undefined,
2043+
localization: undefined,
2044+
requestLocale: undefined,
2045+
accessToken: undefined,
2046+
authGate: undefined,
2047+
});
2048+
const attachData = () => ({ id: 'a_new', file_id: 'f_new', parent_object: 'att_secret', parent_id: 'r1' });
2049+
2050+
it('an anonymous create of an attachment is refused (403 RECORD_NOT_ACCESSIBLE) and writes no row', async () => {
2051+
const { ql, remaining } = await bootWired({ sharing: { canEdit: async () => true }, parents: ['r1'] });
2052+
await expect(
2053+
ql.insert('sys_attachment', attachData(), { context: guestContext() } as any),
2054+
).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 });
2055+
expect(remaining()).toBe(0);
2056+
});
2057+
2058+
it('CONTROL: the same create by a signed-in caller resolves and stamps that caller as the uploader', async () => {
2059+
const { ql, driver } = await bootWired({ sharing: { canEdit: async () => true }, parents: ['r1'] });
2060+
await ql.insert('sys_attachment', attachData(), { context: { userId: 'u1' } } as any);
2061+
expect(driver.stores.get('sys_attachment')!.get('a_new')).toMatchObject({ uploaded_by: 'u1' });
2062+
});
2063+
});

0 commit comments

Comments
 (0)