From 5d0e6e30abac326d5f42eed9f39d880e98ed4849 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 23:46:20 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(security)!:=20the=20owner-anchor=20sta?= =?UTF-8?q?mp's=20guest=20branch=20=E2=80=94=20a=20guest=20never=20owns,?= =?UTF-8?q?=20never=20assigns,=20and=20is=20never=20stamped=20(ADR-0138=20?= =?UTF-8?q?D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner-anchor stamp (step 3.5 of the security middleware) gains its guest branch. For the anonymous principal it stamps nothing and leaves an absent or empty owner unset. It refuses a guest-supplied owner on insert and on update with 403 PERMISSION_DENIED, whatever the guest is granted: the transfer grant is never consulted for a guest. The guest fact is read through one module-level reader, which the set resolution and the organization refusal now ask too. Pins: plugin-security unit cases with member controls, and a booted dogfood fixture at the class-4 endpoint door and the form door. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .../src/security-plugin.test.ts | 148 +++++++ .../plugin-security/src/security-plugin.ts | 55 ++- .../test/guest-owner-stamp.dogfood.test.ts | 400 ++++++++++++++++++ 3 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index b4f42a7eead..d008fbeab95 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -592,6 +592,154 @@ describe('SecurityPlugin', () => { }); }); + // ------------------------------------------------------------------------- + // [ADR-0138 D1] The owner-anchor stamp's GUEST branch: the guest is a + // principal, never an owner. The anonymous principal is stamped with + // nothing, its empty owner stays unset, and an owner it supplies is refused + // on insert and on update WHATEVER it is granted — the sets below carry the + // transfer bit and `modifyAllRecords`, which the anchor tier keeps off a real + // guest binding, precisely so the refusal is shown not to lean on that tier. + // Every refusal carries its control: the same write by a signed-in member, + // and the same guest write without the owner. The door-level pins, on a + // booted stack, are `packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts`. + // ------------------------------------------------------------------------- + describe('owner_id anchor guard — the guest branch (ADR-0138 D1)', () => { + const guestWriter: PermissionSet = { + name: 'synth_guest_writer', + label: 'Guest writer', + objects: { task: { allowRead: true, allowCreate: true } }, + } as any; + const guestTransfer: PermissionSet = { + name: 'synth_guest_transfer', + label: 'Guest writer with transfer', + objects: { task: { allowRead: true, allowCreate: true, allowEdit: true, allowTransfer: true } }, + } as any; + const guestModifyAll: PermissionSet = { + name: 'synth_guest_modify_all', + label: 'Guest writer with modify-all', + objects: { task: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true } }, + } as any; + const memberSet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } }, + } as any; + const memberTransfer: PermissionSet = { + name: 'can_transfer', + label: 'Transfer', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowTransfer: true } }, + } as any; + const ALL = [guestWriter, guestTransfer, guestModifyAll, memberSet, memberTransfer]; + + const boot = async (findOneImpl?: (query: any) => any) => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: ALL, findOneImpl }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + return harness; + }; + const guestCtx = (permissions: string[]) => + ({ principalKind: 'guest', positions: ['guest'], isSystem: false, permissions, tenantId: 'org-1' }); + const memberCtx = (permissions: string[] = []) => + ({ principalKind: 'human', userId: 'u1', tenantId: 'org-1', positions: [], permissions }); + + /** The middleware's verdict: admitted, or the refusal's ADR-0112 `code` + `status`. */ + const verdict = async (harness: { run(opCtx: any): Promise }, opCtx: any) => { + try { + await harness.run(opCtx); + return { admitted: true } as const; + } catch (e) { + const err = e as { code?: unknown; status?: unknown }; + return { admitted: false, code: err.code, status: err.status } as const; + } + }; + const REFUSED = { admitted: false, code: 'PERMISSION_DENIED', status: 403 } as const; + const GUEST_GRANTS: Array<[string, string[]]> = [ + ['create only', [guestWriter.name]], + ['the transfer bit', [guestTransfer.name]], + ['modifyAllRecords', [guestModifyAll.name]], + ]; + const FORGED_OWNERS = ['u_victim', 'usr_system', 'guest']; + + it('a guest insert with no owner is stamped with nothing — the row stays unset', async () => { + const harness = await boot(); + const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: guestCtx([guestWriter.name]) }; + expect(await verdict(harness, opCtx)).toEqual({ admitted: true }); + expect(opCtx.data).not.toHaveProperty('owner_id'); + // Control: the stamp is live — a signed-in member's same insert is stamped. + const member: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: memberCtx() }; + expect(await verdict(harness, member)).toEqual({ admitted: true }); + expect(member.data.owner_id).toBe('u1'); + }); + + it('a guest\'s empty owner (null or an empty string) is left unset, never a value', async () => { + const harness = await boot(); + for (const empty of [null, '']) { + const opCtx: any = { + object: 'task', operation: 'insert', data: { name: 'A', owner_id: empty }, context: guestCtx([guestWriter.name]), + }; + expect(await verdict(harness, opCtx), JSON.stringify(empty)).toEqual({ admitted: true }); + expect(opCtx.data.owner_id, JSON.stringify(empty)).toBeNull(); + } + }); + + it('a guest-supplied owner is refused 403 PERMISSION_DENIED on insert, whatever the guest holds', async () => { + const harness = await boot(); + for (const [grant, permissions] of GUEST_GRANTS) { + for (const owner of FORGED_OWNERS) { + const opCtx: any = { + object: 'task', operation: 'insert', data: { name: 'A', owner_id: owner }, context: guestCtx(permissions), + }; + expect(await verdict(harness, opCtx), `${grant} / ${owner}`).toEqual(REFUSED); + } + // Control: the same guest, the same grant, no owner — admitted. + const plain: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: guestCtx(permissions) }; + expect(await verdict(harness, plain), grant).toEqual({ admitted: true }); + } + // Control: a member holding the transfer grant sets the same owner. + const member: any = { + object: 'task', operation: 'insert', data: { name: 'A', owner_id: 'u_victim' }, context: memberCtx([memberTransfer.name]), + }; + expect(await verdict(harness, member)).toEqual({ admitted: true }); + expect(member.data.owner_id).toBe('u_victim'); + }); + + it('batch: one guest-supplied owner refuses the whole batch, and empty rows stay unset', async () => { + const harness = await boot(); + const forged: any = { + object: 'task', operation: 'insert', data: [{ name: 'a' }, { name: 'b', owner_id: 'u_victim' }], + context: guestCtx([guestTransfer.name]), + }; + expect(await verdict(harness, forged)).toEqual(REFUSED); + const clean: any = { + object: 'task', operation: 'insert', data: [{ name: 'a' }, { name: 'b', owner_id: '' }], + context: guestCtx([guestTransfer.name]), + }; + expect(await verdict(harness, clean)).toEqual({ admitted: true }); + expect(clean.data.map((r: any) => r.owner_id ?? null)).toEqual([null, null]); + }); + + it('a guest update carrying an owner is refused — a transfer, a disown and an echo alike — whatever it holds', async () => { + const harness = await boot(() => ({ id: 't1', owner_id: 'u_victim' })); + for (const [grant, permissions] of GUEST_GRANTS.slice(1)) { + for (const owner of ['u_other', null, 'u_victim']) { + const opCtx: any = { + object: 'task', operation: 'update', data: { id: 't1', owner_id: owner }, context: guestCtx(permissions), + }; + expect(await verdict(harness, opCtx), `${grant} / ${JSON.stringify(owner)}`).toEqual(REFUSED); + } + // Control: the same guest's update without the owner is admitted. + const plain: any = { object: 'task', operation: 'update', data: { id: 't1', name: 'renamed' }, context: guestCtx(permissions) }; + expect(await verdict(harness, plain), grant).toEqual({ admitted: true }); + } + // Control: a member holding the transfer grant transfers the same row. + const member: any = { + object: 'task', operation: 'update', data: { id: 't1', owner_id: 'u_other' }, context: memberCtx([memberTransfer.name]), + }; + expect(await verdict(harness, member)).toEqual({ admitted: true }); + }); + }); + // ------------------------------------------------------------------------- // [ADR-0095 D1 / #2937 Finding 1] step 3.7 — the Layer 0 tenant wall on the // WRITE post-image. A SUPPLIED (non-empty) `organization_id` in an insert / diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 32d05057f48..0ce005f6ed4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -163,6 +163,18 @@ import { import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js'; import { registerBuiltinCapabilities } from './builtin-capabilities.js'; +/** + * [ADR-0138 D9, D1] Whether `context` is the GUEST envelope — the anonymous + * principal the explicit guest entry assembles: `principalKind: 'guest'` and no + * `userId`. The one reading of that fact in this file: the set resolution (D3), + * the organization refusal (D4) and the owner-anchor stamp's guest branch (D1) + * all ask it here. + */ +function isGuestEnvelope(context: unknown): boolean { + const c = context as { principalKind?: unknown; userId?: unknown } | null | undefined; + return c?.principalKind === 'guest' && !c?.userId; +} + /** * [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the * platform-scoped `systemPermissions` that `admin_full_access` carries and @@ -2405,6 +2417,9 @@ export class SecurityPlugin implements Plugin { // a zero-section form falls open to — one of these columns). // Ownership stays NULL for object hooks / the first-admin // bootstrap to assign, exactly like other anonymous-seeded rows. + // [ADR-0138 D1] This is the form doors' half of "the guest never + // owns"; the endpoint doors' half is the owner-anchor stamp's guest + // branch (step 3.5), which a grant context never reaches. if (opCtx.operation === 'insert' && opCtx.data && typeof opCtx.data === 'object') { const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; const stripped = new Set(); @@ -3342,6 +3357,21 @@ export class SecurityPlugin implements Plugin { // back) is tolerated by comparing against the pre-image; a bulk // change-set carrying `owner_id` has no pre-image to compare and // fails CLOSED. + // • GUEST — [ADR-0138 D1] the guest is a principal, never an owner. For + // the anonymous principal ({@link isGuestEnvelope}) the step stamps + // nothing: there is no user id to stamp, and nothing stands in for + // one — never the guest, never the system principal — so an absent or + // empty owner is left unset. A guest-SUPPLIED owner, on insert or on + // update, is a forgery and is refused WHATEVER the guest is granted: + // the transfer grant is never consulted for it (the anchor tier keeps + // the transfer bit off a guest binding, and this branch does not lean + // on that). Who owns a guest-written row is the scenario's own + // metadata, and its writers never meet this step as the guest's + // value: a `beforeInsert` hook runs inside the engine's operation, + // after this middleware, and a record-change flow writes as itself. + // So "supplied" is told apart from "set by the scenario" by where the + // value came from — the payload the guest's write arrived with — and + // never by what the value is. // // System/boot writes carry `isSystem` and short-circuited the whole // middleware above — imports, OAuth provisioning, cron snapshots and @@ -3395,6 +3425,18 @@ export class SecurityPlugin implements Plugin { { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, ); }; + // [ADR-0138 D1] The guest branch (see the step's header). Its refusal + // names no grant to acquire, because none admits a guest's owner. + const guest = isGuestEnvelope(opCtx.context); + const denyGuestOwnerWrite = (): never => { + throw new PermissionDeniedError( + `[Security] Access denied: 'owner_id' on '${opCtx.object}' cannot be set by an anonymous caller — ` + + `a guest never owns a record and never assigns one, whatever it is granted. Leave 'owner_id' out ` + + `of the request: the owner of a row a guest writes is set by the object's own hooks or ` + + `record-change flows, and stays unset when none sets it (ADR-0138 D1)`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + ); + }; // Lazily evaluated + memoized (incl. a `false` result): the common // path pays nothing, and ADR-0090 D10 — under delegation BOTH // principals must independently hold the transfer grant. @@ -3416,6 +3458,12 @@ export class SecurityPlugin implements Plugin { // single-record-only stamp left bulk-inserted rows NULL-owned // and invisible to their creator). if (userId) row.owner_id = userId; + // [ADR-0138 D1 item 5] A guest is stamped with nothing, and + // its empty owner is left UNSET: an empty string would land + // in the anchor as a value that is neither an owner nor none. + else if (guest && row.owner_id === '') row.owner_id = null; + } else if (guest) { + denyGuestOwnerWrite(); } else if ( !(isScalarId(row.owner_id) && userId != null && String(row.owner_id) === String(userId)) && !hasTransferGrant() @@ -3433,6 +3481,9 @@ export class SecurityPlugin implements Plugin { const single = !Array.isArray(opCtx.data); for (const row of rows) { if (!writesOwner(row)) continue; + // [ADR-0138 D1] A guest assigns no ownership — not a transfer, + // not a disown, not an echo — and no grant changes that. + if (guest) denyGuestOwnerWrite(); if (hasTransferGrant()) break; // authorized transfer — allow every row let unchanged = false; if (single && isScalarId(row.owner_id)) { @@ -6483,7 +6534,7 @@ export class SecurityPlugin implements Plugin { // never reaches the guest. The baseline does not reach it either (applied // only to a caller with a user id, below), and `everyone` is never among // its positions. - const isGuest = context?.principalKind === 'guest' && !context?.userId; + const isGuest = isGuestEnvelope(context); const positions = isGuest ? [] : (context?.positions ?? []); const explicitPermissionSets = context?.permissions ?? []; const requested = [...positions, ...explicitPermissionSets]; @@ -8766,7 +8817,7 @@ export class SecurityPlugin implements Plugin { // {@link resolveOperationPrincipals} return before it for one (the // middleware's system short-circuit, and `checkControlledByParentWrite`'s // first exit), so this rule reads no elevation of its own. - if (context?.principalKind !== 'guest' || context?.userId) return; + if (!isGuestEnvelope(context)) return; const tenantId = context?.tenantId; if (typeof tenantId === 'string' && tenantId !== '') return; const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture); diff --git a/packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts b/packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts new file mode 100644 index 00000000000..9e2f1caaefb --- /dev/null +++ b/packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts @@ -0,0 +1,400 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0138 E3 — the owner-anchor stamp's guest branch (D1: the guest is a +// principal, never an owner), at the real doors, on a booted stack. +// +// The record's row for E3, one pin per clause: +// +// 1. a guest insert with nothing in the scenario setting an owner leaves it +// unset — the stamp writes nothing for a caller with no user id; +// 2. an owner set by a hook or a record-change flow stands — those writers +// run AFTER the stamp (a `beforeInsert` hook inside the engine's +// operation) or as their own write (a flow), so the stamp never sees +// their value as one the guest sent; +// 3. a guest-supplied owner is refused at an endpoint (D2 class 4) and +// stripped at the form doors (D2 class 1, unchanged); +// 4. the owner column never holds the guest or the system principal. +// +// WHY A SET CARRYING THE TRANSFER BIT IS BOUND TO THE GUEST HERE. The anchor +// tier refuses such a set at every door that binds one +// (`describeAnchorForbiddenBits`, asserted below), so on a real deployment the +// generic forge rule already refuses a guest's owner for want of the transfer +// grant. The guest branch must not lean on that: D1 says the guest never owns +// and never assigns, whatever it is granted. The binding is installed through +// the registry's bare slot — the one route a boot has to that state, as the E1 +// fixture beside this one explains — so the pins prove the branch holds on its +// own, and an ablation of it turns them red. +// +// There is no first-class assignment-rule mechanism on this platform: an +// assignment rule is written as a hook or a record-change flow, the two +// writers pin 2 exercises. +// +// The fixture is inline and throwaway. Every anonymous endpoint carries the +// armed rate limit ADR-0121 D6 makes its publish obligation. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { defineStack, defineView, defineFlow } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { definePermissionSet, describeAnchorForbiddenBits } from '@objectstack/spec/security'; +import { SystemUserId } from '@objectstack/spec/system'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SYS = { context: { isSystem: true } } as const; +const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 1_000 }; +const BASE = '/api/v1/apps/e3guest'; +const FORM_SLUG = 'e3guest-intake'; +/** The user the scenario's own writers assign a guest-written row to. */ +const QUEUE_USER = 'usr_e3guest_queue'; +/** Every row the guest writes is named with this prefix, so the census can find them all. */ +const GUEST_ROW = 'e3guest-anon-'; + +const OBJ = { + lead: 'e3guest_lead', + hooked: 'e3guest_hooked', + flowed: 'e3guest_flowed', + intake: 'e3guest_intake', +} as const; + +const object = (name: string) => + ObjectSchema.create({ + name, + label: name, + pluralLabel: name, + sharingModel: 'public_read_write', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }); + +/** The set bound to the guest: create (and read) on the three endpoint objects — the anchor tier admits it. */ +const GUEST_WRITER = 'e3guest_writer'; +const GuestWriter = definePermissionSet({ + name: GUEST_WRITER, + label: 'E3 guest writer', + objects: { + [OBJ.lead]: { allowRead: true, allowCreate: true }, + [OBJ.hooked]: { allowRead: true, allowCreate: true }, + [OBJ.flowed]: { allowRead: true, allowCreate: true }, + }, +}); + +/** The same, plus the transfer bit — refused by the anchor tier, installed through the bare slot. */ +const GUEST_TRANSFER = 'e3guest_writer_transfer'; +const GuestWriterTransfer = definePermissionSet({ + name: GUEST_TRANSFER, + label: 'E3 guest writer with transfer', + objects: { [OBJ.lead]: { allowRead: true, allowCreate: true, allowTransfer: true } }, +}); + +/** The scenario's own record-change flow: it assigns every new flowed row to the queue user. */ +const AssignFlow = defineFlow({ + name: 'e3guest_assign_owner', + label: 'Assign the owner of a new request', + description: 'Runs on create and assigns the row to the intake queue.', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'On create', config: { objectName: OBJ.flowed, triggerType: 'record-after-create' } }, + { + id: 'assign', + type: 'update_record', + label: 'Assign the owner', + config: { + objectName: OBJ.flowed, + filter: { id: { dialect: 'cel', source: 'record.id' } }, + fields: { owner_id: QUEUE_USER }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'assign' }, + { id: 'e2', source: 'assign', target: 'end' }, + ], +}); + +const create = (key: 'lead' | 'hooked' | 'flowed'): ApiEndpoint => ({ + name: `e3guest_create_${key}`, + path: `${BASE}/${key}`, + method: 'POST', + summary: `create ${key}`, + description: `anonymous create on ${OBJ[key]}`, + type: 'object_operation', + objectParams: { object: OBJ[key], operation: 'create' }, + authRequired: false, + rateLimit: ARMED, +} as ApiEndpoint); + +const intakeData = { provider: 'object' as const, object: OBJ.intake }; +const IntakeViews = defineView({ + list: { label: 'Intake', type: 'grid', data: intakeData, columns: [{ field: 'name' }] }, + formViews: { + intake: { + type: 'simple', + data: intakeData, + sections: [{ name: 'intake', label: 'Intake', columns: 1, fields: [{ field: 'name', required: true }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${FORM_SLUG}` }, + }, + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.guest-owner-stamp', + namespace: 'e3guest', + version: '0.0.0', + type: 'app', + name: 'Guest Owner Stamp Fixture', + description: 'ADR-0138 E3: the owner-anchor stamp for a guest, at the class-4 endpoint door and the form door.', + }, + // ADR-0097: the `record-after-create` start node needs the trigger pair declared. + requires: ['automation', 'triggers'], + objects: [object(OBJ.lead), object(OBJ.hooked), object(OBJ.flowed), object(OBJ.intake)], + views: [IntakeViews], + flows: [AssignFlow], + permissions: [GuestWriter, GuestWriterTransfer], + apis: [create('lead'), create('hooked'), create('flowed')], +} as any); + +let stack: VerifyStack; +let tempDir: string; +let adminToken: string; +let adminId: string; +let ql: IObjectQLEngine; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'os-e3-guest-')); + const artifactPath = join(tempDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(fixtureStack)); + stack = await bootStack(fixtureStack, { + automation: true, + extraPlugins: [ + new RecordChangeTriggerPlugin(), + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + adminToken = await stack.signIn(); + ql = stack.kernel.getService('objectql'); + + const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 5, ...SYS }); + expect(orgs, 'the fixture boots the single-organization deployment the guest entry derives from').toHaveLength(1); + const admins = await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, fields: ['id'], limit: 2, ...SYS }); + expect(admins).toHaveLength(1); + adminId = String(admins[0].id); + + // The scenario's assignee, and the legacy system-principal row an older + // runtime may still hold — present, so a forged reference to it resolves and + // only the stamp's own verdict stands between it and the column. + await ql.insert('sys_user', { id: QUEUE_USER, email: 'e3guest-queue@example.test', name: 'Intake queue', email_verified: true }, SYS); + await ql.insert('sys_user', { id: SystemUserId.SYSTEM, email: 'e3guest-system@example.test', name: 'System', email_verified: true }, SYS); + + // The scenario's own hook: every new hooked row is assigned to the queue user. + (ql as unknown as HookSeam).registerHook( + 'beforeInsert', + async (hookCtx: any) => { + if (hookCtx?.input?.data && typeof hookCtx.input.data === 'object') hookCtx.input.data.owner_id = QUEUE_USER; + }, + { object: OBJ.hooked }, + ); + + bindGuest([GUEST_WRITER]); +}, 180_000); + +afterAll(async () => { + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +/** The engine's hook and registry seams — not part of the engine slot's contract, so typed here, narrowly. */ +interface HookSeam { + registerHook(event: string, handler: (ctx: unknown) => Promise, options?: { object?: string }): void; +} +interface RegistrySeam { + registry: { registerItem(type: string, item: Record, key: string): void }; +} + +/** Declare a definition of the `guest` anchor in the registry's bare slot — where an environment definition answers first. */ +function bindGuest(permissionSets: string[]): void { + (ql as unknown as RegistrySeam).registry.registerItem('position', { name: 'guest', label: 'guest', permissionSets }, 'name'); +} + +async function anonymousCreate(key: 'lead' | 'hooked' | 'flowed', body: Record) { + const res = await stack.api(`/apps/e3guest/${key}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let parsed: any; + try { parsed = JSON.parse(text); } catch { parsed = undefined; } + return { status: res.status, body: parsed, text }; +} + +async function rowsNamed(object: string, name: string): Promise { + return (await ql.find(object, { where: { name }, limit: 5, ...SYS })) as any[]; +} + +/** The owner of the one row named `name` on `object`, as stored. */ +async function storedOwner(object: string, name: string): Promise { + const rows = await rowsNamed(object, name); + expect(rows, `exactly one '${name}' row on ${object}`).toHaveLength(1); + return rows[0].owner_id ?? null; +} + +/** 403 PERMISSION_DENIED (ADR-0112: code + status), and the row was never written. */ +async function expectOwnerRefused(res: { status: number; body: any; text: string }, object: string, name: string) { + expect(res.status, res.text).toBe(403); + expect(res.body?.error?.code, res.text).toBe('PERMISSION_DENIED'); + expect(await rowsNamed(object, name), 'a refused guest write lands no row').toHaveLength(0); +} + +describe('ADR-0138 E3 pin 1 — a guest insert with nothing setting an owner leaves it unset', () => { + it('the class-4 door writes the guest\'s row with no owner — never the guest, never the system principal', async () => { + const name = `${GUEST_ROW}lead-plain`; + const res = await anonymousCreate('lead', { name }); + expect(res.status, res.text).toBe(201); + expect(await storedOwner(OBJ.lead, name)).toBeNull(); + }); + + it('a supplied empty owner is no owner: the row stays unset', async () => { + for (const [suffix, owner_id] of [['null', null], ['blank', '']] as const) { + const name = `${GUEST_ROW}lead-empty-${suffix}`; + const res = await anonymousCreate('lead', { name, owner_id }); + expect(res.status, res.text).toBe(201); + expect(await storedOwner(OBJ.lead, name)).toBeNull(); + } + }); + + it('control: the stamp is live on this object — a signed-in caller\'s row is stamped with its own id', async () => { + const name = 'e3guest-admin-stamped'; + const res = await stack.apiAs(adminToken, 'POST', `/data/${OBJ.lead}`, { name }); + expect(res.status, await res.clone().text()).toBe(201); + expect(await storedOwner(OBJ.lead, name)).toBe(adminId); + }); +}); + +describe('ADR-0138 E3 pin 2 — an owner the scenario sets stands', () => { + it('a `beforeInsert` hook\'s owner stands on the guest\'s row', async () => { + const name = `${GUEST_ROW}hooked`; + const res = await anonymousCreate('hooked', { name }); + expect(res.status, res.text).toBe(201); + expect(await storedOwner(OBJ.hooked, name)).toBe(QUEUE_USER); + }); + + it('a record-change flow\'s owner stands on the guest\'s row', async () => { + const name = `${GUEST_ROW}flowed`; + const res = await anonymousCreate('flowed', { name }); + expect(res.status, res.text).toBe(201); + // The flow is its own write, after the insert; wait for it, bounded. + let owner: unknown = null; + for (let i = 0; i < 50 && owner !== QUEUE_USER; i += 1) { + owner = await storedOwner(OBJ.flowed, name); + if (owner !== QUEUE_USER) await new Promise((r) => setTimeout(r, 100)); + } + expect(owner).toBe(QUEUE_USER); + }); + + it('control: without the scenario\'s writer the same guest row has no owner, so the owner above is theirs', async () => { + // Pin 1's row: the plain object carries no hook and no flow. + expect(await storedOwner(OBJ.lead, `${GUEST_ROW}lead-plain`)).toBeNull(); + }); +}); + +describe('ADR-0138 E3 pin 3 — a guest-supplied owner is refused at the endpoint door and stripped at the form door', () => { + it('the class-4 door refuses a guest naming a real user as the owner', async () => { + const name = `${GUEST_ROW}lead-forged-admin`; + await expectOwnerRefused(await anonymousCreate('lead', { name, owner_id: adminId }), OBJ.lead, name); + }); + + it('the refusal holds whatever the guest is granted: a guest holding the transfer bit is refused too', async () => { + // Production cannot bind this set to the guest — the anchor tier refuses it. + expect(describeAnchorForbiddenBits(GuestWriterTransfer, 'guest')).not.toBeNull(); + bindGuest([GUEST_TRANSFER, GUEST_WRITER]); + try { + const name = `${GUEST_ROW}lead-forged-admin-transfer`; + await expectOwnerRefused(await anonymousCreate('lead', { name, owner_id: adminId }), OBJ.lead, name); + // Control: the same guest, the same grant, no owner — admitted and unset. + const plain = `${GUEST_ROW}lead-transfer-plain`; + const res = await anonymousCreate('lead', { name: plain }); + expect(res.status, res.text).toBe(201); + expect(await storedOwner(OBJ.lead, plain)).toBeNull(); + } finally { + bindGuest([GUEST_WRITER]); + } + }); + + it('control: a caller who holds the transfer authority sets the same owner on the same object', async () => { + const name = 'e3guest-admin-assigned'; + const res = await stack.apiAs(adminToken, 'POST', `/data/${OBJ.lead}`, { name, owner_id: QUEUE_USER }); + expect(res.status, await res.clone().text()).toBe(201); + expect(await storedOwner(OBJ.lead, name)).toBe(QUEUE_USER); + }); + + it('the form door strips a submitted owner and leaves the row unset, as it does today', async () => { + const name = `${GUEST_ROW}intake-forged`; + const res = await stack.api(`/forms/${FORM_SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, owner_id: adminId }), + }); + const text = await res.text(); + expect(res.status, text).toBe(201); + expect(await storedOwner(OBJ.intake, name)).toBeNull(); + }); + + it('control: the form object\'s owner column takes a value from a caller with the authority', async () => { + const name = 'e3guest-admin-intake'; + const res = await stack.apiAs(adminToken, 'POST', `/data/${OBJ.intake}`, { name, owner_id: QUEUE_USER }); + expect(res.status, await res.clone().text()).toBe(201); + expect(await storedOwner(OBJ.intake, name)).toBe(QUEUE_USER); + }); +}); + +describe('ADR-0138 E3 pin 4 — the owner column never holds the guest or the system principal', () => { + it('a guest naming the system principal, or itself, as the owner is refused — the transfer bit notwithstanding', async () => { + bindGuest([GUEST_TRANSFER, GUEST_WRITER]); + try { + for (const [suffix, owner] of [['system', SystemUserId.SYSTEM], ['guest', 'guest']] as const) { + const name = `${GUEST_ROW}lead-forged-${suffix}`; + await expectOwnerRefused(await anonymousCreate('lead', { name, owner_id: owner }), OBJ.lead, name); + } + } finally { + bindGuest([GUEST_WRITER]); + } + }); + + it('census: every row the guest wrote holds no owner or the scenario\'s, and none holds the guest or the system principal', async () => { + const census: Array<{ object: string; name: string; owner: unknown }> = []; + for (const object of Object.values(OBJ)) { + const rows = (await ql.find(object, { fields: ['name', 'owner_id'], limit: 500, ...SYS })) as any[]; + for (const row of rows) { + if (String(row.name).startsWith(GUEST_ROW)) census.push({ object, name: row.name, owner: row.owner_id ?? null }); + } + } + // The census saw every guest row the pins above wrote, and only those. + expect(census.map((c) => c.name).sort()).toEqual([ + `${GUEST_ROW}flowed`, + `${GUEST_ROW}hooked`, + `${GUEST_ROW}intake-forged`, + `${GUEST_ROW}lead-empty-blank`, + `${GUEST_ROW}lead-empty-null`, + `${GUEST_ROW}lead-plain`, + `${GUEST_ROW}lead-transfer-plain`, + ]); + for (const entry of census) { + expect([null, QUEUE_USER], `${entry.object} '${entry.name}'`).toContain(entry.owner); + } + }); +}); From 3e44b3d5ee4433b23235997eaf91f71f3d266484 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 23:48:54 +0000 Subject: [PATCH 2/2] chore(changeset): the owner-anchor stamp's guest branch, a narrowing for plugin-security (ADR-0138 E3) Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .changeset/23226-guest-owner-stamp.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/23226-guest-owner-stamp.md diff --git a/.changeset/23226-guest-owner-stamp.md b/.changeset/23226-guest-owner-stamp.md new file mode 100644 index 00000000000..0be015d4794 --- /dev/null +++ b/.changeset/23226-guest-owner-stamp.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(security)!: an anonymous caller never owns a record and never sets its owner — the owner-anchor stamp's guest branch (ADR-0138 E3, D1) + +Clause-②: no (narrowing) — a guest-supplied owner is refused at the endpoints and the owner column never holds the guest or the system principal. That tightens what a guest write may carry and admits nothing refused today. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0138 D1 rules that the guest is a principal, never an owner. The owner-anchor stamp in `SecurityPlugin` (the step that stamps an absent `owner_id` with the acting user and refuses a forged one) now carries the guest branch the record names as D1's enforcement point. It applies to the guest envelope, an anonymous request served by an `authRequired: false` endpoint: + +- **Nothing is stamped for a guest.** An absent owner stays unset: never the guest, never the system principal. An empty `owner_id` (an empty string) is left unset too, where it used to be stored as an empty string. +- **A guest-supplied owner is refused**, on create and on update, with `PERMISSION_DENIED` (`403`), whatever the guest is granted. The transfer grant is never consulted for a guest. Before this change a guest whose bound permission set carried the transfer bit could create a row owned by any user, the system principal included. The anchor tier already refuses that bit on a guest binding, so no binding a deployment can make today reached it, and this branch does not depend on the tier. Without the transfer bit the refusal already happened before this change; its message now says that no grant admits a guest's owner, instead of naming the transfer grant as the remedy. +- **An owner the scenario sets stands.** A `beforeInsert` hook runs after the stamp, inside the engine's operation, and a record-change flow writes as itself, so neither is mistaken for a value the guest sent. + +Unchanged: the public form doors, which strip a submitted owner and leave it unset (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`); every signed-in caller, whose absent owner is still stamped with its own id and whose foreign owner still needs the transfer grant; and system writes. + +What a guest client does: leave `owner_id` out of an anonymous write. The object's own hooks or record-change flows decide who owns the row, and the row stays unowned when none does.