Skip to content

Commit e790cfc

Browse files
feat(security)!: an anonymous caller never owns a record and never sets its owner (ADR-0138 E3) (#23353)
Fixes #23226 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. ADR-0138 **E3**, D1 ("the guest is a principal, never an owner"). This lands the guest branch of the owner-anchor stamp in `SecurityPlugin`, which the record names as D1's enforcement point. Claim `6157234533`, branch `claude/issue-23226-guest-owner-stamp`. ⛔ Nothing in the record is re-decided. One measurement departs from D1 outside the stamp. It is raised to the seat, not decided here (see **Raised, not decided**). ## What changed (`@objectstack/plugin-security` only) - **Step 3.5, the guest branch.** It applies to the guest envelope (`principalKind: 'guest'`, no `userId`): - Nothing is stamped. An absent owner stays unset. An empty-string owner is also left unset; it used to be stored as an empty string. - A guest-supplied owner is refused with `403 PERMISSION_DENIED`, on insert and on update (a transfer, a disown or an echo), **whatever the guest holds**. The transfer grant is never consulted for a guest, and the refusal names no grant to acquire. - Callers with a user id keep the stamp and the forge rule unchanged. - **One reader of the guest fact.** A module-level `isGuestEnvelope` holds E1's own predicate (`principalKind === 'guest' && !userId`). The set resolution (E1, D3) and the organization refusal (E1, D4) now call it too, instead of keeping two inline copies. This adds no new guest predicate, and `plugin-sharing`'s module-private `isGuestPrincipal` is not reused. - **The `publicFormGrant` branch** is unchanged. It gains a one-line ADR-0138 D1 note. - The diff stays clear of the regions #23334 changed (the catalog-echo registration and the `kernel:ready` pass). #23334 merged first, and `main` is merged in (`ab5209be3f`), with no conflict. ## Measured on `bbb9757717`, before the change (the PM's H1 to H4) - **H1. What a guest write stored at the doors E1 opened.** - **Class-4 endpoint door** (`authRequired: false`, `type: 'object_operation'`): - With no owner supplied, no owner was stored. The stamp writes `userId` only when one is present. - A supplied empty string was stored as an empty string. - A supplied owner was refused only because the guest lacked the transfer grant. When the guest's bound set carried the transfer bit, the forged owner was stored: a real user's id, and also the system principal's `usr_system` (present as a legacy row). - The anchor tier refuses that bit at every binding door, so no binding a deployment can make today reached this. - **Class-5 flow door:** - A `runAs: 'user'` flow is refused before its data node reaches the engine. That is already pinned by `declarative-endpoint-anonymous-guest.dogfood.test.ts`. - A `runAs: 'system'` flow bypasses the middleware (see Acceptance notes). - **H2. Held.** The guest fact is E1's predicate, factored into one reader. - **H3. Held, by order.** - A `beforeInsert` hook runs inside `engine.insert`'s operation, after the middleware chain: `triggerHooks('beforeInsert')` sits in the executor that `executeWithMiddleware` wraps. - A record-change flow is its own write. - No middleware registered ahead of the security middleware writes `owner_id`. - So any `owner_id` on a guest write's payload at step 3.5 arrived with that write. "Supplied" and "set by the scenario" are told apart by where the value came from, never by its content. - **H4. Held.** `PUBLIC_FORM_SERVER_MANAGED_FIELDS` carries `owner_id`. The form route's filter and the `publicFormGrant` strip both drop it, and the form row stays unowned. `packages/spec` is untouched. - **Assignment rules.** The platform has no first-class assignment-rule mechanism (zero source hits). An assignment rule is written as a hook or a record-change flow, the two writers pin 2 exercises. ## Pins, each with a negative control | Row clause | Pin | Negative control | |:--|:--|:--| | 1. A guest insert with nothing setting an owner leaves it unset | dogfood, class-4 door: the stored owner is null, for an absent owner, a `null` owner and an empty-string owner. Unit: nothing is stamped, and empty owners are left null | Control: a signed-in caller's row on the same object is stamped with its own id. Mutation leg B (the stamp writes the system principal for a guest) turns pin 1 red | | 2. An owner set by a hook, flow or assignment rule stands | dogfood: a code-registered `beforeInsert` hook's owner and a `runAs: 'system'` record-change flow's owner both stand on the guest's row | Control: the plain object's guest row, with no writer, is unset, so the owner comes from the scenario's writer. Under leg B the hook and the flow still win, and the no-writer control turns red | | 3. A guest-supplied owner is refused at an endpoint and stripped at the form doors | dogfood: `403 PERMISSION_DENIED` (code and status), and no row lands, with a plain set and with a transfer-bit set (the set is checked against `describeAnchorForbiddenBits` and refused). The form door strips a submitted owner. Unit: refused for create-only, transfer-bit and modify-all guests, three forged owners each, batch rows, and updates | Controls: the same guest and the same grant with no owner are admitted. A caller with the transfer authority sets the same owner on the same object, the form object included. Leg A turns the transfer-bit pin red | | 4. The owner column never holds the guest or the system principal | dogfood: a guest naming `usr_system` or `guest` as owner is refused despite the transfer bit. A census of every guest-written row finds only null or the scenario's user | Legs A and B both turn it red | ## Ablation The fix was committed first (`3e44b3d5ee`). Each leg ran through `scripts/ablation-replace.mjs` in wrap mode, with a `trap` restore. Each leg rebuilt `plugin-security` and ran `scripts/ablation-dist-preflight.mjs` before reading any result. No ablation file is kept. | Leg | Mutation (anchor hit 1, blob changed) | dist reading | Unit (guest branch, 5) | Dogfood (13) | |:--|:--|:--|:--|:--| | A: remove the guest branch | `const guest = isGuestEnvelope(opCtx.context);` becomes `const guest = false;` | `--absent`: marker gone from all 6 built files, tree on the mutate leg | **4 red**: the empty owner is kept as `''`, and the transfer-bit and modify-all guests are admitted on insert, batch and update | **4 red**: the empty owner reads `expected '' to be null`, pin 3 transfer-bit and pin 4 read `expected 201 to be 403`, and the census finds 8 guest rows instead of 7 | | B: the stamp writes the system principal for a guest | `else if (guest) row.owner_id = 'usr_system';` | present in `index.js` and `index.mjs` | **3 red** | **5 red**: pin 1 reads `expected 'usr_system' to be null` (three rows), the pin 2 control and the census fail | | R: restore | blob `0ce005f6ed` equals HEAD, `git diff HEAD` is empty, porcelain is empty | `isGuestEnvelope(opCtx.context)` present again, and leg B's marker `--absent` | 5/5 | 13/13 | **The direction observed, as it fell.** - Removing the guest branch does **not** turn pin 1's absent-owner case red. The stamp's existing `if (userId)` guard already writes nothing for a caller with no user id, so that case has no guest-specific code to remove. - Its teeth are mutation leg B instead, and the member control. - Leg A turns red the clauses that depend on the branch: the empty owner, pin 3 with the transfer bit, and pin 4. ## Tests - **`@objectstack/plugin-security`:** - Full suite at `ab5209be3f`, in two shards: 189 files, 3883 passed, 45 skipped. - `typecheck`: 0 errors, and `check:test-typecheck` is OK. `--listFiles` shows `security-plugin.test.ts` is in `tsconfig.test.json`. - **`@objectstack/dogfood`:** - At `ab5209be3f`, on the rebuilt dependency closure: the new file 13/13. With 14 neighbouring suites (guest grants, anonymous endpoint, endpoint policy, owner anchor and bulk writes, the public form suites, seed-ownership claim, anonymous door sweep, authz conformance, showcase anonymous deny, form self-auth): 15 files, 162 passed. - `typecheck` is 0 errors, and `--listFiles` includes the new file. - **ESLint, as a proven narrowing:** - `eslint --no-inline-config --format json` over the 3 changed `.ts` files: 3 files, 0 errors, 0 warnings, at `ab5209be3f`. - The population is the root `eslint.config.mjs`'s `**/*.{ts,...}` block, and no file was reported ignored. - Invariance: that config enables no type-aware linting (no `parserOptions.project`, no typed rules, per its own header), so this diff cannot move any untouched file's verdict. - The full `pnpm lint` is CI's. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, at `ab5209be3f`: 70 commands, all exit 0. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`, because 8 unrelated packages had no `dist/`. Those were built, and it re-ran green. - `--ran` reconciliation: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN", "a DERIVED zero — all 70 recorded an exit code and none of them is 3". - `check:adr-0087-registration`: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition." The marker reads `.changeset/23226-guest-owner-stamp.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, which is the record row's "none: no new key, and nothing moves". - Labels: the dispatch names none, and `skip-changeset` does not apply (the package publishes). Zero label writes. ## Raised, not decided **The seed-settle claim hands guest-written rows to the platform admin.** - What happens: `SecurityPlugin.claimSeedOwnershipOnSettle` runs `claimSeedOwnership` on every `app:seeded`, which fires on every boot whose app declares seed data. It re-owns every row of a user-authored object whose `owner_id` is null (or `usr_system`) to the platform admin. Its predicate cannot tell a seed row from a guest-written or form-door row. - Measured on a booted stack (a scratch probe, not committed): a guest row and a form-door row, both unowned, became the platform admin's after one `app:seeded`. The log line read `handed 2 seeded record(s) to platform admin`. - The record says otherwise: - D1 item 3: "The platform stamps nothing on a guest insert and declares no organization-level default owner". - D1 item 5: "a guest-written row keeps no owner, exactly as the public form doors' intake does today". The form doors' rows are claimed the same way today. - This sits outside the stamp, the record's named enforcement point, and nothing in this diff depends on how it is answered. The question and its options are in the `os-dev-report` comment on #23226. ## Acceptance notes - **The class-5 flow door under `runAs: 'system'`.** Until D2b's publish refusal lands (card E2), such a flow writes as the system principal. That bypasses the middleware, this branch included. The row's owner is whatever the flow's nodes set, and a node can map a request input into `owner_id`. Carrier: E2's card. - **The ADR anchor registry.** `scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json` lists ADR-0055, ADR-0106 and ADR-0123. ADR-0138 is cited in the code at each site but is not added to the registry, which is outside the claim's file surface. Carrier: none. - **File surface, against the claim.** All edits are in the claimed `security-plugin.ts`: step 3.5, E1's `:6486` read and its D4 refusal read (now through the one reader), and a comment in the `publicFormGrant` branch. The rest is its tests (a unit block in `security-plugin.test.ts`, and the dogfood file) and one changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 228357a commit e790cfc

4 files changed

Lines changed: 622 additions & 2 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
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)
6+
7+
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.
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, accept set of a metadata schema or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. What narrows is a value an anonymous caller may send in a write, judged by the security middleware at run time; no metadata names it, so there is nothing for `objectstack migrate meta` to rewrite, and the record's own row for this card states the disposition "none: no new key, and nothing moves". The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this and this diff adds none (not registered or already-registered); no exported TypeScript declaration changes (not runtime-interface-only or type-surface-only); no export is removed (not runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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:
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
19+
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.
20+
21+
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.

‎packages/plugins/plugin-security/src/security-plugin.test.ts‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,154 @@ describe('SecurityPlugin', () => {
592592
});
593593
});
594594

595+
// -------------------------------------------------------------------------
596+
// [ADR-0138 D1] The owner-anchor stamp's GUEST branch: the guest is a
597+
// principal, never an owner. The anonymous principal is stamped with
598+
// nothing, its empty owner stays unset, and an owner it supplies is refused
599+
// on insert and on update WHATEVER it is granted — the sets below carry the
600+
// transfer bit and `modifyAllRecords`, which the anchor tier keeps off a real
601+
// guest binding, precisely so the refusal is shown not to lean on that tier.
602+
// Every refusal carries its control: the same write by a signed-in member,
603+
// and the same guest write without the owner. The door-level pins, on a
604+
// booted stack, are `packages/qa/dogfood/test/guest-owner-stamp.dogfood.test.ts`.
605+
// -------------------------------------------------------------------------
606+
describe('owner_id anchor guard — the guest branch (ADR-0138 D1)', () => {
607+
const guestWriter: PermissionSet = {
608+
name: 'synth_guest_writer',
609+
label: 'Guest writer',
610+
objects: { task: { allowRead: true, allowCreate: true } },
611+
} as any;
612+
const guestTransfer: PermissionSet = {
613+
name: 'synth_guest_transfer',
614+
label: 'Guest writer with transfer',
615+
objects: { task: { allowRead: true, allowCreate: true, allowEdit: true, allowTransfer: true } },
616+
} as any;
617+
const guestModifyAll: PermissionSet = {
618+
name: 'synth_guest_modify_all',
619+
label: 'Guest writer with modify-all',
620+
objects: { task: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true } },
621+
} as any;
622+
const memberSet: PermissionSet = {
623+
name: 'member_default',
624+
label: 'Member',
625+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } },
626+
} as any;
627+
const memberTransfer: PermissionSet = {
628+
name: 'can_transfer',
629+
label: 'Transfer',
630+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowTransfer: true } },
631+
} as any;
632+
const ALL = [guestWriter, guestTransfer, guestModifyAll, memberSet, memberTransfer];
633+
634+
const boot = async (findOneImpl?: (query: any) => any) => {
635+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
636+
const harness = makeMiddlewareCtx({ permissionSets: ALL, findOneImpl });
637+
await plugin.init(harness.ctx);
638+
await plugin.start(harness.ctx);
639+
return harness;
640+
};
641+
const guestCtx = (permissions: string[]) =>
642+
({ principalKind: 'guest', positions: ['guest'], isSystem: false, permissions, tenantId: 'org-1' });
643+
const memberCtx = (permissions: string[] = []) =>
644+
({ principalKind: 'human', userId: 'u1', tenantId: 'org-1', positions: [], permissions });
645+
646+
/** The middleware's verdict: admitted, or the refusal's ADR-0112 `code` + `status`. */
647+
const verdict = async (harness: { run(opCtx: any): Promise<any> }, opCtx: any) => {
648+
try {
649+
await harness.run(opCtx);
650+
return { admitted: true } as const;
651+
} catch (e) {
652+
const err = e as { code?: unknown; status?: unknown };
653+
return { admitted: false, code: err.code, status: err.status } as const;
654+
}
655+
};
656+
const REFUSED = { admitted: false, code: 'PERMISSION_DENIED', status: 403 } as const;
657+
const GUEST_GRANTS: Array<[string, string[]]> = [
658+
['create only', [guestWriter.name]],
659+
['the transfer bit', [guestTransfer.name]],
660+
['modifyAllRecords', [guestModifyAll.name]],
661+
];
662+
const FORGED_OWNERS = ['u_victim', 'usr_system', 'guest'];
663+
664+
it('a guest insert with no owner is stamped with nothing — the row stays unset', async () => {
665+
const harness = await boot();
666+
const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: guestCtx([guestWriter.name]) };
667+
expect(await verdict(harness, opCtx)).toEqual({ admitted: true });
668+
expect(opCtx.data).not.toHaveProperty('owner_id');
669+
// Control: the stamp is live — a signed-in member's same insert is stamped.
670+
const member: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: memberCtx() };
671+
expect(await verdict(harness, member)).toEqual({ admitted: true });
672+
expect(member.data.owner_id).toBe('u1');
673+
});
674+
675+
it('a guest\'s empty owner (null or an empty string) is left unset, never a value', async () => {
676+
const harness = await boot();
677+
for (const empty of [null, '']) {
678+
const opCtx: any = {
679+
object: 'task', operation: 'insert', data: { name: 'A', owner_id: empty }, context: guestCtx([guestWriter.name]),
680+
};
681+
expect(await verdict(harness, opCtx), JSON.stringify(empty)).toEqual({ admitted: true });
682+
expect(opCtx.data.owner_id, JSON.stringify(empty)).toBeNull();
683+
}
684+
});
685+
686+
it('a guest-supplied owner is refused 403 PERMISSION_DENIED on insert, whatever the guest holds', async () => {
687+
const harness = await boot();
688+
for (const [grant, permissions] of GUEST_GRANTS) {
689+
for (const owner of FORGED_OWNERS) {
690+
const opCtx: any = {
691+
object: 'task', operation: 'insert', data: { name: 'A', owner_id: owner }, context: guestCtx(permissions),
692+
};
693+
expect(await verdict(harness, opCtx), `${grant} / ${owner}`).toEqual(REFUSED);
694+
}
695+
// Control: the same guest, the same grant, no owner — admitted.
696+
const plain: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: guestCtx(permissions) };
697+
expect(await verdict(harness, plain), grant).toEqual({ admitted: true });
698+
}
699+
// Control: a member holding the transfer grant sets the same owner.
700+
const member: any = {
701+
object: 'task', operation: 'insert', data: { name: 'A', owner_id: 'u_victim' }, context: memberCtx([memberTransfer.name]),
702+
};
703+
expect(await verdict(harness, member)).toEqual({ admitted: true });
704+
expect(member.data.owner_id).toBe('u_victim');
705+
});
706+
707+
it('batch: one guest-supplied owner refuses the whole batch, and empty rows stay unset', async () => {
708+
const harness = await boot();
709+
const forged: any = {
710+
object: 'task', operation: 'insert', data: [{ name: 'a' }, { name: 'b', owner_id: 'u_victim' }],
711+
context: guestCtx([guestTransfer.name]),
712+
};
713+
expect(await verdict(harness, forged)).toEqual(REFUSED);
714+
const clean: any = {
715+
object: 'task', operation: 'insert', data: [{ name: 'a' }, { name: 'b', owner_id: '' }],
716+
context: guestCtx([guestTransfer.name]),
717+
};
718+
expect(await verdict(harness, clean)).toEqual({ admitted: true });
719+
expect(clean.data.map((r: any) => r.owner_id ?? null)).toEqual([null, null]);
720+
});
721+
722+
it('a guest update carrying an owner is refused — a transfer, a disown and an echo alike — whatever it holds', async () => {
723+
const harness = await boot(() => ({ id: 't1', owner_id: 'u_victim' }));
724+
for (const [grant, permissions] of GUEST_GRANTS.slice(1)) {
725+
for (const owner of ['u_other', null, 'u_victim']) {
726+
const opCtx: any = {
727+
object: 'task', operation: 'update', data: { id: 't1', owner_id: owner }, context: guestCtx(permissions),
728+
};
729+
expect(await verdict(harness, opCtx), `${grant} / ${JSON.stringify(owner)}`).toEqual(REFUSED);
730+
}
731+
// Control: the same guest's update without the owner is admitted.
732+
const plain: any = { object: 'task', operation: 'update', data: { id: 't1', name: 'renamed' }, context: guestCtx(permissions) };
733+
expect(await verdict(harness, plain), grant).toEqual({ admitted: true });
734+
}
735+
// Control: a member holding the transfer grant transfers the same row.
736+
const member: any = {
737+
object: 'task', operation: 'update', data: { id: 't1', owner_id: 'u_other' }, context: memberCtx([memberTransfer.name]),
738+
};
739+
expect(await verdict(harness, member)).toEqual({ admitted: true });
740+
});
741+
});
742+
595743
// -------------------------------------------------------------------------
596744
// [ADR-0095 D1 / #2937 Finding 1] step 3.7 — the Layer 0 tenant wall on the
597745
// WRITE post-image. A SUPPLIED (non-empty) `organization_id` in an insert /

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@ import {
164164
import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js';
165165
import { registerBuiltinCapabilities } from './builtin-capabilities.js';
166166

167+
/**
168+
* [ADR-0138 D9, D1] Whether `context` is the GUEST envelope — the anonymous
169+
* principal the explicit guest entry assembles: `principalKind: 'guest'` and no
170+
* `userId`. The one reading of that fact in this file: the set resolution (D3),
171+
* the organization refusal (D4) and the owner-anchor stamp's guest branch (D1)
172+
* all ask it here.
173+
*/
174+
function isGuestEnvelope(context: unknown): boolean {
175+
const c = context as { principalKind?: unknown; userId?: unknown } | null | undefined;
176+
return c?.principalKind === 'guest' && !c?.userId;
177+
}
178+
167179
/**
168180
* [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the
169181
* platform-scoped `systemPermissions` that `admin_full_access` carries and
@@ -2406,6 +2418,9 @@ export class SecurityPlugin implements Plugin {
24062418
// a zero-section form falls open to — one of these columns).
24072419
// Ownership stays NULL for object hooks / the first-admin
24082420
// bootstrap to assign, exactly like other anonymous-seeded rows.
2421+
// [ADR-0138 D1] This is the form doors' half of "the guest never
2422+
// owns"; the endpoint doors' half is the owner-anchor stamp's guest
2423+
// branch (step 3.5), which a grant context never reaches.
24092424
if (opCtx.operation === 'insert' && opCtx.data && typeof opCtx.data === 'object') {
24102425
const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data];
24112426
const stripped = new Set<string>();
@@ -3343,6 +3358,21 @@ export class SecurityPlugin implements Plugin {
33433358
// back) is tolerated by comparing against the pre-image; a bulk
33443359
// change-set carrying `owner_id` has no pre-image to compare and
33453360
// fails CLOSED.
3361+
// • GUEST — [ADR-0138 D1] the guest is a principal, never an owner. For
3362+
// the anonymous principal ({@link isGuestEnvelope}) the step stamps
3363+
// nothing: there is no user id to stamp, and nothing stands in for
3364+
// one — never the guest, never the system principal — so an absent or
3365+
// empty owner is left unset. A guest-SUPPLIED owner, on insert or on
3366+
// update, is a forgery and is refused WHATEVER the guest is granted:
3367+
// the transfer grant is never consulted for it (the anchor tier keeps
3368+
// the transfer bit off a guest binding, and this branch does not lean
3369+
// on that). Who owns a guest-written row is the scenario's own
3370+
// metadata, and its writers never meet this step as the guest's
3371+
// value: a `beforeInsert` hook runs inside the engine's operation,
3372+
// after this middleware, and a record-change flow writes as itself.
3373+
// So "supplied" is told apart from "set by the scenario" by where the
3374+
// value came from — the payload the guest's write arrived with — and
3375+
// never by what the value is.
33463376
//
33473377
// System/boot writes carry `isSystem` and short-circuited the whole
33483378
// middleware above — imports, OAuth provisioning, cron snapshots and
@@ -3396,6 +3426,18 @@ export class SecurityPlugin implements Plugin {
33963426
{ operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
33973427
);
33983428
};
3429+
// [ADR-0138 D1] The guest branch (see the step's header). Its refusal
3430+
// names no grant to acquire, because none admits a guest's owner.
3431+
const guest = isGuestEnvelope(opCtx.context);
3432+
const denyGuestOwnerWrite = (): never => {
3433+
throw new PermissionDeniedError(
3434+
`[Security] Access denied: 'owner_id' on '${opCtx.object}' cannot be set by an anonymous caller — ` +
3435+
`a guest never owns a record and never assigns one, whatever it is granted. Leave 'owner_id' out ` +
3436+
`of the request: the owner of a row a guest writes is set by the object's own hooks or ` +
3437+
`record-change flows, and stays unset when none sets it (ADR-0138 D1)`,
3438+
{ operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
3439+
);
3440+
};
33993441
// Lazily evaluated + memoized (incl. a `false` result): the common
34003442
// path pays nothing, and ADR-0090 D10 — under delegation BOTH
34013443
// principals must independently hold the transfer grant.
@@ -3417,6 +3459,12 @@ export class SecurityPlugin implements Plugin {
34173459
// single-record-only stamp left bulk-inserted rows NULL-owned
34183460
// and invisible to their creator).
34193461
if (userId) row.owner_id = userId;
3462+
// [ADR-0138 D1 item 5] A guest is stamped with nothing, and
3463+
// its empty owner is left UNSET: an empty string would land
3464+
// in the anchor as a value that is neither an owner nor none.
3465+
else if (guest && row.owner_id === '') row.owner_id = null;
3466+
} else if (guest) {
3467+
denyGuestOwnerWrite();
34203468
} else if (
34213469
!(isScalarId(row.owner_id) && userId != null && String(row.owner_id) === String(userId)) &&
34223470
!hasTransferGrant()
@@ -3434,6 +3482,9 @@ export class SecurityPlugin implements Plugin {
34343482
const single = !Array.isArray(opCtx.data);
34353483
for (const row of rows) {
34363484
if (!writesOwner(row)) continue;
3485+
// [ADR-0138 D1] A guest assigns no ownership — not a transfer,
3486+
// not a disown, not an echo — and no grant changes that.
3487+
if (guest) denyGuestOwnerWrite();
34373488
if (hasTransferGrant()) break; // authorized transfer — allow every row
34383489
let unchanged = false;
34393490
if (single && isScalarId(row.owner_id)) {
@@ -6511,7 +6562,7 @@ export class SecurityPlugin implements Plugin {
65116562
// never reaches the guest. The baseline does not reach it either (applied
65126563
// only to a caller with a user id, below), and `everyone` is never among
65136564
// its positions.
6514-
const isGuest = context?.principalKind === 'guest' && !context?.userId;
6565+
const isGuest = isGuestEnvelope(context);
65156566
const positions = isGuest ? [] : (context?.positions ?? []);
65166567
const explicitPermissionSets = context?.permissions ?? [];
65176568
const requested = [...positions, ...explicitPermissionSets];
@@ -8794,7 +8845,7 @@ export class SecurityPlugin implements Plugin {
87948845
// {@link resolveOperationPrincipals} return before it for one (the
87958846
// middleware's system short-circuit, and `checkControlledByParentWrite`'s
87968847
// first exit), so this rule reads no elevation of its own.
8797-
if (context?.principalKind !== 'guest' || context?.userId) return;
8848+
if (!isGuestEnvelope(context)) return;
87988849
const tenantId = context?.tenantId;
87998850
if (typeof tenantId === 'string' && tenantId !== '') return;
88008851
const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture);

0 commit comments

Comments
 (0)