diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index f6dae615a99..b4d9d42fbcb 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -591,3 +591,41 @@ describe('ruling 6355206139 — the resolver’s audience stamp rides the envelo expect(Object.keys(assemble({ ...ANONYMOUS, audience: 'external' }))).not.toContain('audience'); }); }); + +// The guest envelope is the ONLY thing that tells a downstream create hook an +// anonymous request is a caller at all. Create hooks such as `sys_attachment`'s +// and `sys_comment`'s keep a bare-kernel carve-out — no hook session means an +// internal, context-less engine call, skipped before any user check — and the +// engine builds that session from the envelope's caller fields +// (`buildSession`, objectql `engine-execution-plumbing.ts`), which does not +// read `principalKind`. So the anonymous principal must stay identified by a +// field that survives that hop, whatever else the face resolved or withheld. +// objectql's own suite pins the hop itself; this pin holds the envelope end. +describe('a guest request is a named caller, never an empty context', () => { + const GUEST_SHAPES: Array<[string, ResolvedAuthzContext]> = [ + // The minimum: no session, no key, no guest anchor grants — nothing but + // the fact that someone asked. + ['no tenant, no grants, no token', ANONYMOUS], + // A face that serves the guest resolves the anchor's grants in the + // organization ADR-0138 D4 derives; the identification must not depend on it. + ['guest anchor grants in an organization', baseAuthz({ tenantId: 'org1', permissions: ['task.read'] })], + ]; + + for (const [name, authz] of GUEST_SHAPES) { + it(`an anonymous request keeps the guest position on its envelope (${name}), so create hooks see a caller`, () => { + const ctx = assembleExecutionContextOrGuest({ + authz, + oauth: undefined, + // Everything a face could pass for an anonymous request, so a later + // change that strips more of it cannot strip the identification too. + localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }, + requestLocale: 'en-US', + accessToken: undefined, + authGate: undefined, + }); + expect(ctx.userId).toBeUndefined(); + expect(ctx.isSystem).toBe(false); + expect(ctx.positions).toEqual(['guest']); + }); + } +}); diff --git a/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts b/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts index c12fe4a2c9d..f0b2dfc1d9a 100644 --- a/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts +++ b/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts @@ -8,6 +8,7 @@ */ import { describe, it, expect } from 'vitest'; +import { assembleExecutionContextOrGuest } from '@objectstack/core'; import { buildSession } from './engine-execution-plumbing.js'; describe('[#26170] buildSession carries the caller\'s locale', () => { @@ -25,3 +26,46 @@ describe('[#26170] buildSession carries the caller\'s locale', () => { } }); }); + +// Create hooks with a bare-kernel carve-out (`sys_attachment`'s and +// `sys_comment`'s) read "no hook session" as an internal, context-less engine +// call and skip their user checks for it. That reading is sound only while +// every request door hands the engine a context this function turns into a +// session — the guest door included, whose envelope names no user. Built +// through the REAL guest entry (`@objectstack/core`), not a literal, so a +// change to what that entry emits is measured here rather than assumed. +describe('a guest request reaches create hooks with a session, so an anonymous create is never treated as a bare-kernel call', () => { + // The anonymous resolver's own answer for a request with no credential: + // the initial context `resolveAuthzContext` returns before any principal + // is found (`packages/core/src/security/resolve-authz-context.ts`). + const anonymousAuthz = () => ({ + positions: [] as string[], + permissions: [] as string[], + directPermissions: [] as string[], + systemPermissions: [] as string[], + org_user_ids: [] as string[], + accessible_org_ids: [] as string[], + }); + + it('the guest envelope with no tenant, no grants and no token still yields a session that names no user', () => { + const guest = assembleExecutionContextOrGuest({ + authz: anonymousAuthz(), + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + const session = buildSession(guest); + expect(session).toBeDefined(); + expect(session?.userId).toBeUndefined(); + // Not the system arm either: the hooks test `isSystem` before the user. + expect(session).not.toHaveProperty('isSystem'); + }); + + it('CONTROL: an envelope that names nothing the session copies yields no session — the bare-kernel reading', () => { + // The carve-out's own premise, so the pin above cannot pass on a + // `buildSession` that answers a session for every input. + expect(buildSession({ principalKind: 'guest' } as any)).toBeUndefined(); + }); +}); diff --git a/packages/services/service-storage/src/attachment-access-hooks.test.ts b/packages/services/service-storage/src/attachment-access-hooks.test.ts index bb634d334e3..ad45b1b0b84 100644 --- a/packages/services/service-storage/src/attachment-access-hooks.test.ts +++ b/packages/services/service-storage/src/attachment-access-hooks.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi } from 'vitest'; +import { assembleExecutionContextOrGuest } from '@objectstack/core'; import { ObjectQL, assertEngineFindOnePredicate } from '@objectstack/objectql'; import { renderOperationMessage } from '@objectstack/spec/system'; 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 expect(authorizeFileRead).not.toHaveBeenCalled(); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// The guest door through the WIRED engine. The create gate skips a call that +// reaches it with no hook session (an internal, context-less engine call); +// a request that came through the guest door must never read as one. The +// envelope is the REAL guest entry's (`@objectstack/core`), handed to the +// REAL engine, so both the envelope and the engine's session hop are the +// production ones — the same rig as the unscoped-write blocks above, with +// the same rebuild-before-trusting note. +// ───────────────────────────────────────────────────────────────────────── + +describe('a guest request through the wired engine is refused at create, never treated as a bare-kernel call', () => { + const guestContext = () => + assembleExecutionContextOrGuest({ + // The anonymous resolver's answer for a request with no credential. + authz: { + positions: [], + permissions: [], + directPermissions: [], + systemPermissions: [], + org_user_ids: [], + accessible_org_ids: [], + }, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + const attachData = () => ({ id: 'a_new', file_id: 'f_new', parent_object: 'att_secret', parent_id: 'r1' }); + + it('an anonymous create of an attachment is refused (403 RECORD_NOT_ACCESSIBLE) and writes no row', async () => { + const { ql, remaining } = await bootWired({ sharing: { canEdit: async () => true }, parents: ['r1'] }); + await expect( + ql.insert('sys_attachment', attachData(), { context: guestContext() } as any), + ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect(remaining()).toBe(0); + }); + + it('CONTROL: the same create by a signed-in caller resolves and stamps that caller as the uploader', async () => { + const { ql, driver } = await bootWired({ sharing: { canEdit: async () => true }, parents: ['r1'] }); + await ql.insert('sys_attachment', attachData(), { context: { userId: 'u1' } } as any); + expect(driver.stores.get('sys_attachment')!.get('a_new')).toMatchObject({ uploaded_by: 'u1' }); + }); +});