Skip to content

Commit a3bcbcf

Browse files
feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) (#22297)
Fixes #21908 Clause-②: no (narrowing) ## Summary ADR-0096 D5 strict mode, the deny round of #21908. A data-engine context that carries no principal (no user id, no position, no permission set) and is not a system context is now **refused** at every place `plugin-security` used to hand it through. The published contract sentence on `ChatWithToolsOptions.toolExecutionContext` ("unauthenticated (RLS-on, sees-nothing)") now holds. Before the deny, every producer of such a context that a run or a static read found was closed inside this claim: it carries the caller's principal or the explicit system opt-in (`isSystem: true`) inside its owning code. Breaking for in-process code (the `!` banner); the changeset carries the migration and an ADR-0087 disposition. The at-tier contract review is owed before the queue. The ADR-0096 D5 dated note is its own Tier H draft PR, on `claude/issue-21908-adr-0096-d5-note`, for the maintainer's approval. ## What changes, by class The predicate is `isPrincipalLessContext(context) && !isSystem`, the one the hand-off already used. These are the six sites, at `origin/main` `0e9371f0` positions: | Site (`security-plugin.ts`) | Before | After | |---|---|---| | engine middleware (near `:2616`) | `return next()`: no CRUD gate, no RLS, no field mask, no tenant wall | throws `PermissionDeniedError` (`403 PERMISSION_DENIED`) for every verb, before the operation runs | | `getReadFilter` (near `:5875`) | the sharing predicate alone | the deny filter (zero rows) | | `resolveProjectionFieldMask` (near `:6126`) | the full field set | no early answer: the zero-set field answers, like any caller that resolves no set | | `canReadObject` (near `:6299`) | `true` | `false` | | `canWriteObject` (near `:6585`) | `true` | `false` | | `canExport` (near `:6688`) | `true` | `false` | - **Unchanged:** a system context is admitted everywhere. - **Unchanged:** a context that carries a principal without a user id is decided by what it carries. That covers a named permission set, the guest principal, and the public-form grant. - **The gates that run before the refusal keep their own answers:** package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-admin. **Why the five service sites return rather than throw.** The ruling's error class is the engine-level refusal, and the middleware throws it. The published `ISecurityService` contract has `canReadObject`, `canWriteObject` and `canExport` *return* `false`, and has `getReadFilter` *return* the deny filter. Each of them now gives a principal-less context the deny it already gives a caller that carries a principal and resolves no set (ADR-0056 D2). The field projections are field answers, not admission answers. The contract text for `getReadableFields` names the answer for "a non-system caller who resolves no permission set", and that answer now applies to this context too. Object admission is refused by the middleware and the three probes. ## Producer census (H1), by class, position and function **Method.** - A temporary probe recorded the call stack at every principal-less exit while the deny was in place. It was never committed; it was reverted, and the blob was proven equal to HEAD. - It ran over the full dogfood suite (10 shards, 239 files) and over every unit file in 22 packages that composes the security plugin (about 90 files). - A static sweep covered every `sys_*` engine call in production source that passes no context. - After the fixes, a real boot was driven, with a member's first query and the admin authoring doors. | Class | Position | Function | From a request | Closure | |---|---|---|---|---| | production, measured | `metadata-protocol` `protocol.ts` | `promoteDraftForPublish` (the draft read) | yes: the publish door, behind its authorization | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `resolveDraftOrgScopeForPublish` (both probes) | yes: an organization-scoped publish | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `resolveMetaItemOrgScope` (both history probes) | yes: the organization-scoped revert path | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `auditMetaItem` | yes: the audit door | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `diffMetaItem` (the history read and the current-row read) | yes: the diff door | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `listCommits`, `revertCommit`, `rollbackToPackageCommit` | yes: the commit doors | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `metaTypeNamespaceExists`, `migrateStoredMetadata` | yes: the authoring and migration doors | explicit system opt-in | | production, static | `metadata-protocol` `protocol.ts` | `deleteMetaItem` legacy path (read and delete) | yes: the code-only delete on a control-plane kernel | explicit system opt-in | | test harness | `qa/dogfood` | the federated-fixture test (context passed as the options argument) | no | carries `{ context }` | | test harness | `qa/dogfood` | `package-first-authoring` (a direct store insert) | no | system opt-in | | test harness | `runtime` | `standalone-stack-seeder-declaration-copy` (context-less reads) | no | system opt-in | | test harness | `runtime` | the `share-links-enforcement-context` engine double (dropped the trailing read options) | no | honours the trailing context, as `mergeReadContext` does | **Not producers today.** `loadMetaFromDb` and `reportUnhydratableOrgScopedRows` read context-less at boot. They run inside ObjectQL's `start()`, and plugin-security depends on ObjectQL, so they run before the security middleware is registered. Noted, not edited. No producer was found outside `plugin-security`, `protocol.ts` and the test harnesses. The static sweep's other 25 hits all go through a system-context wrapper or a context-carrying options object, or are comments. Every edit uses only the declared opt-in; nothing guesses a boundary. Each moved call is a `sys_metadata*` store read, or the legacy delete of a `sys_metadata` row. None of the six gates the middleware runs before the refusal keys on those objects, so the opt-in changes nothing they decide. **The dispatcher's identity-resolution fault class** (the `domain:cli` seat's note) is closed by this deny by construction. It is pinned below, and `http-dispatcher.ts` is not edited. ## Pins **The ruling's three pins:** - **A principal-less context is refused on read and write:** `plugin-security` `principal-less-strict-mode.test.ts`. It covers every spelling: no context, an empty one, empty arrays, `isSystem: false`, a tenant id alone, and provenance only. The middleware answers `PERMISSION_DENIED` / 403 for all seven verbs before the operation runs, the probes answer `false`, and the row scope is the deny filter. - **An explicit system context is unchanged:** the same file, with and without a principal. - **The dogfood suite is green under the deny:** see Evidence. **Further pins:** - **The hand-through pins became refusal pins:** `zero-set-deny-baseline`, `zero-set-masking`, `zero-set-capability-fold`, `metadata-unresolvable-posture`, `predicate-related-read-tenant-scope` and `security-plugin.test.ts`'s row-scope case. - **The deny is keyed on carrying no principal:** a user-less named set, the guest principal and the public-form grant are each decided by what they carry. - **The `resolveRequestScope` fault class is now denied:** `runtime` `dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts`, over the real dispatcher plugin and the real security middleware. The engine is handed no principal, the store is never reached, and the caller gets 403 with nothing of the rows. Its two controls: an anonymous request with no fault runs as the guest (the posture of #22147, unchanged), and a granted signed-in caller is served. - **The guest posture is unchanged at a real boot:** #22177's `declarative-endpoint-anonymous-guest` pins stay green under the deny. - **The composed Q2 pin:** `qa/dogfood` `owner-of-private-object-under-strict-mode`. An `owner_of:` audience on a `private` object reaches its owner's inbox, and nobody else's, end to end. In the same composition, a principal-less read of that object is refused. - **The protocol producers:** `metadata-protocol` `protocol.platform-store-system-opt-in.test.ts`, deny-round block. Each producer is driven, and every engine call it issues must carry `isSystem: true`. ## Ablations Each ablation ran through `scripts/ablation-replace.mjs` (wrap mode, plus a shell trap). Every mutation landed (anchor 1 → 0, blob changed), and every restore was proven (blob == HEAD, `git diff HEAD` empty). The dist legs were proven with `ablation-dist-preflight` (marker present while live, `--absent` after the restore rebuild, tree clean). | # | Mutation | Red | |---|---|---| | A1 | the middleware refusal replaced by the old `return next()` (dist leg) | plugin-security 13 of 42; runtime fault-class pin 1 of 3 (the store was reached) | | A2 | `getReadFilter` answers no scope for the class | 8 of 305 | | A3 | `canReadObject` admits the class | 7 of 305 | | A4 | the diff door's history read loses the opt-in | 1 of 7 | | A5 | the promotion's draft read loses the opt-in | 1 of 7 | | A6 | `resolveOwnerOf` loses its opt-in (service-messaging, dist leg) | the composed Q2 pin 1 of 1 (0 recipients) | ## Evidence Final head `454d0e89b9`: `origin/main` `8cbe255ef6` merged at `6681b216e1`, then a docblock-only test commit. All runs were made under the shared verify lock, so wall times are shared-box readings. **On the merged tree (`6681b216e1`):** - plugin-security: 180 files, 3800 passed, 45 skipped. - metadata-protocol: 221 files (+3 skipped), 28280 passed. - runtime: 339 files, 5490 passed, 19 skipped. - The composing unit files of the packages `main` touched (rest, cli, lint, platform-objects, plugin-approvals, service-analytics, organizations, spec): all green. - **dogfood, the whole suite in 10 shards: 223 files, 1754 passed, 9 skipped.** - typecheck exits 0 for plugin-security, metadata-protocol, runtime and dogfood. `tsc --listFiles` shows metadata-protocol's program compiles the edited pin. **Before the merge (`8248ceb97f`):** objectql 381 files / 7525 passed, and core 81 files / 2240 passed. Neither package is touched by `main`'s incoming commits or by this diff. **Real boot, showcase `--fresh`, on the merged tree:** - An anonymous request answers 401. - The admin signs in and invites a member. The member signs up, and the member's first data query answers 200, as do the member's object read and inbox. - The admin's draft save, publish, diff, audit, history, delete and package-commits doors all answer 200. - The server log carries zero strict-mode refusals. **Gates:** `dispatch-gates --commands` derived 73 families at `454d0e89b9`. All 73 ran and exited 0. `--ran`: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)". Selected verdict lines: - `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` - `✓ This diff introduces no \`major\` bump.` - `check-nul-bytes: OK` - `check-engine-double-contract: OK` - `check-test-source-alias OK` - `check:cross-package-test-inputs OK` **Lint, a proven narrowing (CI runs the full `pnpm lint`):** - The population read from `eslint.config.mjs` is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` plus the `packages/**` blocks. - `eslint --no-inline-config --format json` over the 16 changed `.ts` files at `454d0e89b9` gives 16 files, 0 errors, 0 warnings. - The config enables no type-aware linting (no `parserOptions.project`, no typed rules), so no untouched file's verdict can move. `check-governed-merges --branch`: 0 of 17 paths on the register; 1083 changed lines. ## Acceptance notes - **The spec's `ISecurityService` prose still describes the hand-off.** Four TSDoc sentences on `getReadFilter`, `canExport` and `canReadObject` in `packages/spec/src/contracts/security-service.ts` say a context with no principal "is admitted" or "keeps the scope". `packages/spec` is outside this claim, so the sentences are not edited here. The report asks the seat how to route them. - **Two of the producers closed here are outside the cross-lane declaration.** The `domain:engine` declaration on #6367 names `promoteDraftForPublish`. The census found the other `protocol.ts` producers listed above in the same file: the same class, with the same mechanical opt-in. They are closed here and named for the seat, so the declaration can be amended. - **A test-side spelling trap.** Passing an `ExecutionContext` variable as the options argument of an engine verb compiles, and silently drops the context. One fixture did this. Under strict mode it is now a loud 403, no longer a silent hand-through. - **What the census cannot see.** The static sweep reads `sys_*` object names spelled as literals; a call whose object name is a variable is covered only by the runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e175479 commit a3bcbcf

17 files changed

Lines changed: 931 additions & 149 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- 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). -->
11+
12+
**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.
13+
14+
**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:
15+
16+
- the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs;
17+
- `canReadObject`, `canExport` and `canWriteObject` answer `false`;
18+
- `getReadFilter` answers the deny filter (zero rows).
19+
20+
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.
21+
22+
**What is unchanged.**
23+
24+
- A system context (`isSystem: true`) is admitted everywhere, as before.
25+
- 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.
26+
- 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.
27+
28+
**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
29+
30+
- pass the caller's execution context, so the caller's own permissions decide; or
31+
- 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 }`.
32+
33+
The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both.
34+
35+
`@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.

‎packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,64 @@ describe('[#21908] row 23 — the repository reads the engine-lane slice left ca
329329
});
330330
});
331331
});
332+
333+
describe('[#21908] the deny round — the platform-store calls still reaching the engine principal-less', () => {
334+
// ADR-0096 D5 strict mode makes the security middleware REFUSE a context
335+
// with no principal and no `isSystem`. Each producer below read or wrote
336+
// the store that way behind a door that had already authorized its caller
337+
// (publish, history, audit, diff, commits, migration, the legacy delete),
338+
// so each carries the explicit opt-in now, like the calls above.
339+
340+
it('the publish path: the promotion draft read and the org-scoped publish probes', async () => {
341+
const { engine, calls } = makeStubEngine();
342+
const protocol = new ObjectStackProtocolImplementation(engine) as any;
343+
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'draft' });
344+
345+
// No package stated: the promotion reads the draft row's own binding.
346+
await expectSystemOptIn(calls, 'publishMetaItem', async () => {
347+
await protocol.publishMetaItem({ type: 'view', name: 'proj_task_grid' });
348+
});
349+
await expectSystemOptIn(calls, 'promoteDraftForPublish', () =>
350+
protocol.promoteDraftForPublish({ type: 'view', name: 'proj_task_grid' }).catch(() => undefined));
351+
await expectSystemOptIn(calls, 'resolveDraftOrgScopeForPublish', () =>
352+
protocol.resolveDraftOrgScopeForPublish('view', 'proj_task_grid', 'org_a'));
353+
});
354+
355+
it('the readers behind the history, audit, diff and commit doors', async () => {
356+
const { engine, calls } = makeStubEngine();
357+
const protocol = new ObjectStackProtocolImplementation(engine) as any;
358+
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' });
359+
360+
await expectSystemOptIn(calls, 'resolveMetaItemOrgScope', () =>
361+
protocol.resolveMetaItemOrgScope('view', 'proj_task_grid', 'org_a'));
362+
await expectSystemOptIn(calls, 'auditMetaItem', () =>
363+
protocol.auditMetaItem({ type: 'view', name: 'proj_task_grid' }));
364+
const diffCalls = await expectSystemOptIn(calls, 'diffMetaItem', () =>
365+
protocol.diffMetaItem({ type: 'view', name: 'proj_task_grid' }));
366+
expect(diffCalls.some((c) => c.table === 'sys_metadata_history' && c.verb === 'find')).toBe(true);
367+
await expectSystemOptIn(calls, 'listCommits', () => protocol.listCommits({ packageId: 'app.pin' }));
368+
await expectSystemOptIn(calls, 'revertCommit', () =>
369+
protocol.revertCommit({ commitId: 'c_absent' }).catch(() => undefined));
370+
await expectSystemOptIn(calls, 'rollbackToPackageCommit', () =>
371+
protocol.rollbackToPackageCommit({ commitId: 'c_absent' }).catch(() => undefined));
372+
});
373+
374+
it('the namespace probe, the migration scan and the legacy (code-only) delete', async () => {
375+
const { engine, calls } = makeStubEngine();
376+
const protocol = new ObjectStackProtocolImplementation(engine) as any;
377+
await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' });
378+
379+
await expectSystemOptIn(calls, 'metaTypeNamespaceExists', () => protocol.metaTypeNamespaceExists('view'));
380+
await expectSystemOptIn(calls, 'migrateStoredMetadata', () => protocol.migrateStoredMetadata({}));
381+
382+
// A code-only row, as a control-plane kernel holds one: the delete
383+
// reaches the legacy path, which reads it and deletes it by id.
384+
await engine.insert('sys_metadata', {
385+
type: 'job', name: 'nightly_job', organization_id: null, package_id: null, state: 'active',
386+
metadata: JSON.stringify({ name: 'nightly_job' }),
387+
}, { context: { isSystem: true } });
388+
const deleteCalls = await expectSystemOptIn(calls, 'deleteMetaItem (legacy path)', () =>
389+
protocol.deleteMetaItem({ type: 'job', name: 'nightly_job' }));
390+
expect(deleteCalls.some((c) => c.verb === 'delete' && c.table === 'sys_metadata')).toBe(true);
391+
});
392+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7114,12 +7114,18 @@ export class ObjectStackProtocolImplementation implements
71147114
requestOrgId: string | null,
71157115
): Promise<string | null> {
71167116
if (requestOrgId === null) return null;
7117+
// [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a
7118+
// platform store read the door that asked already authorized, scoped by
7119+
// the protocol itself (`organization_id` in each `where`). Principal-
7120+
// less, the engine refuses it.
71177121
const inOrg = await this.engine.findOne('sys_metadata_history', {
71187122
where: { organization_id: requestOrgId, type: singularType, name },
7123+
context: { isSystem: true },
71197124
});
71207125
if (inOrg) return requestOrgId;
71217126
const inEnv = await this.engine.findOne('sys_metadata_history', {
71227127
where: { organization_id: null, type: singularType, name },
7128+
context: { isSystem: true },
71237129
});
71247130
return inEnv ? null : requestOrgId;
71257131
}
@@ -7212,12 +7218,18 @@ export class ObjectStackProtocolImplementation implements
72127218
// package, and these probes keep asking that same question. See the
72137219
// docblock above for the ruling commit c74aefe63 records and its accepted narrowing.
72147220
const packageDim = packageId !== undefined ? { package_id: packageId } : {};
7221+
// [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a
7222+
// platform store read the door that asked already authorized, scoped by
7223+
// the protocol itself (`organization_id` in each `where`). Principal-
7224+
// less, the engine refuses it.
72157225
const inOrg = await this.engine.findOne('sys_metadata', {
72167226
where: { organization_id: requestOrgId, type: singularType, name, state: 'draft', ...packageDim },
7227+
context: { isSystem: true },
72177228
});
72187229
if (inOrg) return requestOrgId;
72197230
const inEnv = await this.engine.findOne('sys_metadata', {
72207231
where: { organization_id: null, type: singularType, name, state: 'draft', ...packageDim },
7232+
context: { isSystem: true },
72217233
});
72227234
return inEnv ? null : requestOrgId;
72237235
}
@@ -11298,10 +11310,15 @@ export class ObjectStackProtocolImplementation implements
1129811310
// life and never its recent changes (#4674). The `as any` is gone
1129911311
// for the same reason: `EngineQueryOptionsParsed` rejects the wrong key,
1130011312
// and erasing the type is what let it through.
11313+
// [#21908, ADR-0096 D5] The explicit system opt-in: the audit
11314+
// trail is a platform store the audit door already authorized the
11315+
// caller to read; the protocol builds the `where` itself.
11316+
// Principal-less, the engine refuses it and this door answered 503.
1130111317
const rows = await this.engine.find('sys_metadata_audit', {
1130211318
where,
1130311319
orderBy: [{ field: 'occurred_at', order: 'desc' }],
1130411320
limit,
11321+
context: { isSystem: true },
1130511322
});
1130611323
const events = (Array.isArray(rows) ? rows : []).map((r: any) => ({
1130711324
id: r.id,
@@ -19774,7 +19791,9 @@ export class ObjectStackProtocolImplementation implements
1977419791
*/
1977519792
private async metaTypeNamespaceExists(type: string): Promise<boolean> {
1977619793
try {
19777-
const row = await this.engine.findOne('sys_metadata', { where: { type } });
19794+
// [#21908, ADR-0096 D5] The explicit system opt-in: an existence
19795+
// probe of the platform store, never a read on a caller's behalf.
19796+
const row = await this.engine.findOne('sys_metadata', { where: { type }, context: { isSystem: true } });
1977819797
return row != null;
1977919798
} catch (error) {
1978019799
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
@@ -21392,7 +21411,9 @@ export class ObjectStackProtocolImplementation implements
2139221411
// metadata. Archived bodies are never even read.
2139321412
const rows: any[] = [];
2139421413
for (const state of ['active', 'draft'] as const) {
21395-
rows.push(...await this.engine.find('sys_metadata', { where: { state } }));
21414+
// [#21908, ADR-0096 D5] The explicit system opt-in: the migration
21415+
// door already authorized an operator to scan the whole store.
21416+
rows.push(...await this.engine.find('sys_metadata', { where: { state }, context: { isSystem: true } }));
2139621417
}
2139721418

2139821419
for (const row of rows) {
@@ -22232,8 +22253,16 @@ export class ObjectStackProtocolImplementation implements
2223222253
// is the 503 the lock read raised before this read moved above it.
2223322254
let draftRow: unknown;
2223422255
try {
22256+
// [#21908, ADR-0096 D5] The explicit system opt-in: a platform
22257+
// store read, which no caller's grants scope. The publish door
22258+
// already authorized the caller, the request carries no
22259+
// execution context to forward (only its `actor`), and the
22260+
// protocol scopes the row itself (`organization_id` above),
22261+
// exactly as its sibling store reads do. Principal-less, the
22262+
// engine now refuses it.
2223522263
draftRow = await this.engine.findOne('sys_metadata', {
2223622264
where: { type: singularType, name: request.name, organization_id: orgId, state: 'draft' },
22265+
context: { isSystem: true },
2223722266
});
2223822267
} catch (error) {
2223922268
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
@@ -25164,9 +25193,13 @@ export class ObjectStackProtocolImplementation implements
2516425193
{ organization_id: null },
2516525194
];
2516625195
}
25196+
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
25197+
// platform store the door already authorized; the protocol scopes the
25198+
// `where` by organization itself. Principal-less, the engine refuses it.
2516725199
const rows = (await this.engine.find('sys_metadata_commit', {
2516825200
where,
2516925201
...(request.limit ? { limit: request.limit } : {}),
25202+
context: { isSystem: true },
2517025203
})) as any[];
2517125204
const mapped = rows.map((r) => ({
2517225205
id: r.id,
@@ -25296,7 +25329,10 @@ export class ObjectStackProtocolImplementation implements
2529625329
{ organization_id: null },
2529725330
];
2529825331
}
25299-
const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
25332+
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
25333+
// platform store the door already authorized; the protocol scopes the
25334+
// `where` by organization itself. Principal-less, the engine refuses it.
25335+
const row = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any;
2530025336
if (!row) {
2530125337
const err: any = new Error(`No commit '${request.commitId}'.`);
2530225338
err.code = 'COMMIT_NOT_FOUND';
@@ -25827,7 +25863,10 @@ export class ObjectStackProtocolImplementation implements
2582725863
{ organization_id: null },
2582825864
];
2582925865
}
25830-
const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
25866+
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
25867+
// platform store the door already authorized; the protocol scopes the
25868+
// `where` by organization itself. Principal-less, the engine refuses it.
25869+
const target = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any;
2583125870
if (!target) {
2583225871
const err: any = new Error(`No commit '${request.commitId}'.`);
2583325872
err.code = 'COMMIT_NOT_FOUND';
@@ -26268,12 +26307,18 @@ export class ObjectStackProtocolImplementation implements
2626826307
const histRows: Array<{ version: number; body: Record<string, unknown> | null }> = [];
2626926308
try {
2627026309
const engineAny = this.engine as any;
26310+
// [#21908, ADR-0096 D5] The explicit system opt-in: the history
26311+
// lineage is a platform store the diff door already authorized;
26312+
// the protocol scopes it by organization itself. Principal-less,
26313+
// the engine refuses it, and the `catch` below would have answered
26314+
// an empty diff for an item that has history.
2627126315
const rows = await engineAny.find('sys_metadata_history', {
2627226316
where: {
2627326317
organization_id: orgId,
2627426318
type: singularType,
2627526319
name: request.name,
2627626320
},
26321+
context: { isSystem: true },
2627726322
});
2627826323
rows.sort((a: any, b: any) => (a.version ?? 0) - (b.version ?? 0));
2627926324
for (const r of rows) {
@@ -26360,8 +26405,11 @@ export class ObjectStackProtocolImplementation implements
2636026405
// compared. ⛔ Do not recover a number by matching bodies or hashes
2636126406
// against history: a publish and a revert both write rows whose
2636226407
// bodies repeat earlier ones.
26408+
// [#21908, ADR-0096 D5] The explicit system opt-in, as the history
26409+
// read above.
2636326410
const current = (await this.engine.findOne('sys_metadata', {
2636426411
where: { organization_id: orgId, type: singularType, name: request.name, state: 'active' },
26412+
context: { isSystem: true },
2636526413
})) as { metadata?: unknown; version?: unknown } | null;
2636626414
toBody = current?.metadata == null
2636726415
? null
@@ -26871,7 +26919,12 @@ export class ObjectStackProtocolImplementation implements
2687126919
};
2687226920

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

2688726940
// Storage teardown (opt-in) — see the repo-path branch above.
2688826941
{

‎packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,20 @@ describe('[#3545] unresolvable object metadata — security posture fails closed
145145
await expect(h.run(memberRead())).resolves.toBeDefined();
146146
});
147147

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

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

0 commit comments

Comments
 (0)