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
21 changes: 21 additions & 0 deletions .changeset/23226-guest-owner-stamp.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- 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). -->

**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.
148 changes: 148 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> }, 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 /
Expand Down
55 changes: 53 additions & 2 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,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
Expand Down Expand Up @@ -2406,6 +2418,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<string>();
Expand Down Expand Up @@ -3343,6 +3358,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
Expand Down Expand Up @@ -3396,6 +3426,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.
Expand All @@ -3417,6 +3459,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()
Expand All @@ -3434,6 +3482,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)) {
Expand Down Expand Up @@ -6511,7 +6562,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];
Expand Down Expand Up @@ -8794,7 +8845,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);
Expand Down
Loading
Loading