Skip to content
35 changes: 35 additions & 0 deletions .changeset/21908-principal-less-strict-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/plugin-security': minor
'@objectstack/metadata-protocol': patch
---

feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime admission: the security middleware refuses an engine operation whose context names no user, no position and no permission set and is not a system context, which it used to hand through. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->

**BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes.

**What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way:

- the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs;
- `canReadObject`, `canExport` and `canWriteObject` answer `false`;
- `getReadFilter` answers the deny filter (zero rows).

It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set.

**What is unchanged.**

- A system context (`isSystem: true`) is admitted everywhere, as before.
- A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant.
- An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal.

**What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either

- pass the caller's execution context, so the caller's own permissions decide; or
- for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`.

The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both.

`@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes.
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,64 @@ describe('[#21908] row 23 — the repository reads the engine-lane slice left ca
});
});
});

describe('[#21908] the deny round — the platform-store calls still reaching the engine principal-less', () => {
// ADR-0096 D5 strict mode makes the security middleware REFUSE a context
// with no principal and no `isSystem`. Each producer below read or wrote
// the store that way behind a door that had already authorized its caller
// (publish, history, audit, diff, commits, migration, the legacy delete),
// so each carries the explicit opt-in now, like the calls above.

it('the publish path: the promotion draft read and the org-scoped publish probes', async () => {
const { engine, calls } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine) as any;
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'draft' });

// No package stated: the promotion reads the draft row's own binding.
await expectSystemOptIn(calls, 'publishMetaItem', async () => {
await protocol.publishMetaItem({ type: 'view', name: 'proj_task_grid' });
});
await expectSystemOptIn(calls, 'promoteDraftForPublish', () =>
protocol.promoteDraftForPublish({ type: 'view', name: 'proj_task_grid' }).catch(() => undefined));
await expectSystemOptIn(calls, 'resolveDraftOrgScopeForPublish', () =>
protocol.resolveDraftOrgScopeForPublish('view', 'proj_task_grid', 'org_a'));
});

it('the readers behind the history, audit, diff and commit doors', async () => {
const { engine, calls } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine) as any;
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' });

await expectSystemOptIn(calls, 'resolveMetaItemOrgScope', () =>
protocol.resolveMetaItemOrgScope('view', 'proj_task_grid', 'org_a'));
await expectSystemOptIn(calls, 'auditMetaItem', () =>
protocol.auditMetaItem({ type: 'view', name: 'proj_task_grid' }));
const diffCalls = await expectSystemOptIn(calls, 'diffMetaItem', () =>
protocol.diffMetaItem({ type: 'view', name: 'proj_task_grid' }));
expect(diffCalls.some((c) => c.table === 'sys_metadata_history' && c.verb === 'find')).toBe(true);
await expectSystemOptIn(calls, 'listCommits', () => protocol.listCommits({ packageId: 'app.pin' }));
await expectSystemOptIn(calls, 'revertCommit', () =>
protocol.revertCommit({ commitId: 'c_absent' }).catch(() => undefined));
await expectSystemOptIn(calls, 'rollbackToPackageCommit', () =>
protocol.rollbackToPackageCommit({ commitId: 'c_absent' }).catch(() => undefined));
});

it('the namespace probe, the migration scan and the legacy (code-only) delete', async () => {
const { engine, calls } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine) as any;
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' });

await expectSystemOptIn(calls, 'metaTypeNamespaceExists', () => protocol.metaTypeNamespaceExists('view'));
await expectSystemOptIn(calls, 'migrateStoredMetadata', () => protocol.migrateStoredMetadata({}));

// A code-only row, as a control-plane kernel holds one: the delete
// reaches the legacy path, which reads it and deletes it by id.
await engine.insert('sys_metadata', {
type: 'job', name: 'nightly_job', organization_id: null, package_id: null, state: 'active',
metadata: JSON.stringify({ name: 'nightly_job' }),
}, { context: { isSystem: true } });
const deleteCalls = await expectSystemOptIn(calls, 'deleteMetaItem (legacy path)', () =>
protocol.deleteMetaItem({ type: 'job', name: 'nightly_job' }));
expect(deleteCalls.some((c) => c.verb === 'delete' && c.table === 'sys_metadata')).toBe(true);
});
});
65 changes: 59 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7114,12 +7114,18 @@ export class ObjectStackProtocolImplementation implements
requestOrgId: string | null,
): Promise<string | null> {
if (requestOrgId === null) return null;
// [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a
// platform store read the door that asked already authorized, scoped by
// the protocol itself (`organization_id` in each `where`). Principal-
// less, the engine refuses it.
const inOrg = await this.engine.findOne('sys_metadata_history', {
where: { organization_id: requestOrgId, type: singularType, name },
context: { isSystem: true },
});
if (inOrg) return requestOrgId;
const inEnv = await this.engine.findOne('sys_metadata_history', {
where: { organization_id: null, type: singularType, name },
context: { isSystem: true },
});
return inEnv ? null : requestOrgId;
}
Expand Down Expand Up @@ -7212,12 +7218,18 @@ export class ObjectStackProtocolImplementation implements
// package, and these probes keep asking that same question. See the
// docblock above for the ruling commit c74aefe63 records and its accepted narrowing.
const packageDim = packageId !== undefined ? { package_id: packageId } : {};
// [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a
// platform store read the door that asked already authorized, scoped by
// the protocol itself (`organization_id` in each `where`). Principal-
// less, the engine refuses it.
const inOrg = await this.engine.findOne('sys_metadata', {
where: { organization_id: requestOrgId, type: singularType, name, state: 'draft', ...packageDim },
context: { isSystem: true },
});
if (inOrg) return requestOrgId;
const inEnv = await this.engine.findOne('sys_metadata', {
where: { organization_id: null, type: singularType, name, state: 'draft', ...packageDim },
context: { isSystem: true },
});
return inEnv ? null : requestOrgId;
}
Expand Down Expand Up @@ -11298,10 +11310,15 @@ export class ObjectStackProtocolImplementation implements
// life and never its recent changes (#4674). The `as any` is gone
// for the same reason: `EngineQueryOptionsParsed` rejects the wrong key,
// and erasing the type is what let it through.
// [#21908, ADR-0096 D5] The explicit system opt-in: the audit
// trail is a platform store the audit door already authorized the
// caller to read; the protocol builds the `where` itself.
// Principal-less, the engine refuses it and this door answered 503.
const rows = await this.engine.find('sys_metadata_audit', {
where,
orderBy: [{ field: 'occurred_at', order: 'desc' }],
limit,
context: { isSystem: true },
});
const events = (Array.isArray(rows) ? rows : []).map((r: any) => ({
id: r.id,
Expand Down Expand Up @@ -19774,7 +19791,9 @@ export class ObjectStackProtocolImplementation implements
*/
private async metaTypeNamespaceExists(type: string): Promise<boolean> {
try {
const row = await this.engine.findOne('sys_metadata', { where: { type } });
// [#21908, ADR-0096 D5] The explicit system opt-in: an existence
// probe of the platform store, never a read on a caller's behalf.
const row = await this.engine.findOne('sys_metadata', { where: { type }, context: { isSystem: true } });
return row != null;
} catch (error) {
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
Expand Down Expand Up @@ -21392,7 +21411,9 @@ export class ObjectStackProtocolImplementation implements
// metadata. Archived bodies are never even read.
const rows: any[] = [];
for (const state of ['active', 'draft'] as const) {
rows.push(...await this.engine.find('sys_metadata', { where: { state } }));
// [#21908, ADR-0096 D5] The explicit system opt-in: the migration
// door already authorized an operator to scan the whole store.
rows.push(...await this.engine.find('sys_metadata', { where: { state }, context: { isSystem: true } }));
}

for (const row of rows) {
Expand Down Expand Up @@ -22232,8 +22253,16 @@ export class ObjectStackProtocolImplementation implements
// is the 503 the lock read raised before this read moved above it.
let draftRow: unknown;
try {
// [#21908, ADR-0096 D5] The explicit system opt-in: a platform
// store read, which no caller's grants scope. The publish door
// already authorized the caller, the request carries no
// execution context to forward (only its `actor`), and the
// protocol scopes the row itself (`organization_id` above),
// exactly as its sibling store reads do. Principal-less, the
// engine now refuses it.
draftRow = await this.engine.findOne('sys_metadata', {
where: { type: singularType, name: request.name, organization_id: orgId, state: 'draft' },
context: { isSystem: true },
});
} catch (error) {
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
Expand Down Expand Up @@ -25164,9 +25193,13 @@ export class ObjectStackProtocolImplementation implements
{ organization_id: null },
];
}
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
// platform store the door already authorized; the protocol scopes the
// `where` by organization itself. Principal-less, the engine refuses it.
const rows = (await this.engine.find('sys_metadata_commit', {
where,
...(request.limit ? { limit: request.limit } : {}),
context: { isSystem: true },
})) as any[];
const mapped = rows.map((r) => ({
id: r.id,
Expand Down Expand Up @@ -25296,7 +25329,10 @@ export class ObjectStackProtocolImplementation implements
{ organization_id: null },
];
}
const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
// platform store the door already authorized; the protocol scopes the
// `where` by organization itself. Principal-less, the engine refuses it.
const row = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any;
if (!row) {
const err: any = new Error(`No commit '${request.commitId}'.`);
err.code = 'COMMIT_NOT_FOUND';
Expand Down Expand Up @@ -25827,7 +25863,10 @@ export class ObjectStackProtocolImplementation implements
{ organization_id: null },
];
}
const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
// platform store the door already authorized; the protocol scopes the
// `where` by organization itself. Principal-less, the engine refuses it.
const target = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any;
if (!target) {
const err: any = new Error(`No commit '${request.commitId}'.`);
err.code = 'COMMIT_NOT_FOUND';
Expand Down Expand Up @@ -26268,12 +26307,18 @@ export class ObjectStackProtocolImplementation implements
const histRows: Array<{ version: number; body: Record<string, unknown> | null }> = [];
try {
const engineAny = this.engine as any;
// [#21908, ADR-0096 D5] The explicit system opt-in: the history
// lineage is a platform store the diff door already authorized;
// the protocol scopes it by organization itself. Principal-less,
// the engine refuses it, and the `catch` below would have answered
// an empty diff for an item that has history.
const rows = await engineAny.find('sys_metadata_history', {
where: {
organization_id: orgId,
type: singularType,
name: request.name,
},
context: { isSystem: true },
});
rows.sort((a: any, b: any) => (a.version ?? 0) - (b.version ?? 0));
for (const r of rows) {
Expand Down Expand Up @@ -26360,8 +26405,11 @@ export class ObjectStackProtocolImplementation implements
// compared. ⛔ Do not recover a number by matching bodies or hashes
// against history: a publish and a revert both write rows whose
// bodies repeat earlier ones.
// [#21908, ADR-0096 D5] The explicit system opt-in, as the history
// read above.
const current = (await this.engine.findOne('sys_metadata', {
where: { organization_id: orgId, type: singularType, name: request.name, state: 'active' },
context: { isSystem: true },
})) as { metadata?: unknown; version?: unknown } | null;
toBody = current?.metadata == null
? null
Expand Down Expand Up @@ -26871,7 +26919,12 @@ export class ObjectStackProtocolImplementation implements
};

try {
const existing = await this.engine.findOne('sys_metadata', { where: scopedWhere });
// [#21908, ADR-0096 D5] The explicit system opt-in on the read and
// the delete: a platform store row the delete door already
// authorized the caller to remove, addressed by the protocol's own
// `scopedWhere` and then by id. Principal-less, the engine refuses
// both.
const existing = await this.engine.findOne('sys_metadata', { where: scopedWhere, context: { isSystem: true } });
if (!existing) {
return {
success: true,
Expand All @@ -26882,7 +26935,7 @@ export class ObjectStackProtocolImplementation implements
: `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`,
};
}
await this.engine.delete('sys_metadata', { where: { id: existing.id } });
await this.engine.delete('sys_metadata', { where: { id: existing.id }, context: { isSystem: true } });

// Storage teardown (opt-in) — see the repo-path branch above.
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,20 @@ describe('[#3545] unresolvable object metadata — security posture fails closed
await expect(h.run(memberRead())).resolves.toBeDefined();
});

it('an anonymous request is unaffected — it short-circuits before the posture read', async () => {
it('a principal-less request never reaches the posture read — ADR-0096 D5 strict mode refuses it first', async () => {
// [#21908] It used to be handed through ahead of the posture read. It is
// now refused at that same point, so the outage is still not what
// decides it: the refusal is the strict-mode one, and the posture
// outage is never consulted (nothing is logged for it).
const h = await boot({ resolvable: false });
const anon: any = {
const principalLess: any = {
object: 'task',
operation: 'find',
ast: { where: undefined },
context: { positions: [], permissions: [] }, // no userId
context: { positions: [], permissions: [] }, // no userId, not isSystem
};
await expect(h.run(anon)).resolves.toBeDefined();
await expect(h.run(principalLess)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
expect(h.logger.error).not.toHaveBeenCalled();
});

it('a system/boot operation is unaffected — isSystem short-circuits the middleware', async () => {
Expand Down
Loading
Loading