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
38 changes: 38 additions & 0 deletions packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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' });
});
});
Loading