Skip to content

Commit f7e5624

Browse files
claude[bot]claude
andauthored
fix(metadata-protocol): key the #3050 authoring gate on authoringChannel so ADR-0090 D11 OWD rules run on host-config deployments (#7710)
* fix(metadata-protocol): key the #3050 authoring gate on authoringChannel (#7674) The pre-persistence authoring gate call site in `saveMetaItem` was wrapped in `if (this.environmentId !== undefined)`. The CLI's lightweight host-config assembler constructs `new ObjectQLPlugin()` with no options, so `environmentId` stays undefined on a self-hosted app server whose `PUT /api/v1/meta/*` is an END-USER surface — and plugin-security's ADR-0090 D11 object posture gate (R1 `owd_widening_forbidden`, R2 `owd_external_wider`) therefore ran on no host-config deployment at all. This is the proxy-signal hazard #6710 diagnosed and retired for the sibling #4463 gate; the #3050 call site was simply never moved onto the declared `authoringChannel`. Both doors now read one key, and the default stays the gated one. Also adds the integration coverage whose absence let this survive: the gate was 18/18 green in its unit suite while a repo-wide grep for `owd_external_wider` found only the gate source and that suite. The new `packages/rest/src/meta-object-owd-gate.test.ts` drives R1 and R2 through a real sqlite engine, a real protocol on the host-config topology and the real `PUT /api/v1/meta/object/:name` route — draft path, active path and `?package=` — and pins the negative direction too (legal pairs still save; the `package-author` channel still bypasses). Fixes #7674 * test(rest): clear two CI ratchets the new OWD gate suite tripped (#7674) Both are ratchets on the new integration test's own surface, not on the fix. Neither is remedied by raising a number, and neither was raised. 1. `query-options-erasure` — the test surface grew 242 -> 243. The new site was `engine.find('sys_metadata', { … } as any)` in the suite's persistence probe. The input is not off-contract, so the remedy is the first one the rule's message prescribes: drop the assertion and let it infer against `EngineQueryOptions`, keeping `tsc` as the enforcing channel for those keys (#4674). No `as unknown as EngineQueryOptions` escape is warranted here, and the baseline is unchanged — measured back at 242, the ceiling. 2. `TypeScript Type Check` TEST_DEBT — `@objectstack/rest` measured 159 against a recorded 155, which since #6939 carries no margin. All four were in the new file: one TS2835 (a relative import without its `.js` extension — the trap AGENTS.md names, and the class that is already 124 of this entry's debt) and three TS2554 from hand-rolled `registerObject` calls missing the required `packageId`. The import gains its extension; the three platform-object registrations move onto `registerApp` under `com.objectstack.metadata-objects`, which is the seam `assembleMetadataProtocol` itself uses — so the harness got more faithful rather than merely quieter. Re-measured at exactly 155; the ledger is untouched and no other entry was lowered. Neither ratchet is evaluated by a package's own `test`/`typecheck` scripts — rest's tsconfig excludes its own tests while the TEST_DEBT ratchet measures raw `tsc --noEmit` including them — which is why a green local run said nothing about either. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f1851e commit f7e5624

7 files changed

Lines changed: 559 additions & 36 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Fix: the #3050 pre-persistence authoring gate now keys on the declared `authoringChannel` instead of `environmentId`, so ADR-0090 D11 object posture enforcement reaches host-config deployments.
6+
7+
The gate call site in `saveMetaItem` was wrapped in `if (this.environmentId !== undefined)`. The CLI's lightweight host-config assembler constructs `new ObjectQLPlugin()` with no options, leaving `environmentId` undefined while serving an end-user `PUT /api/v1/meta/*` — so plugin-security's object posture gate (`owd_widening_forbidden` / `owd_external_wider`) ran on no self-hosted deployment at all. This is the same proxy-signal hazard #6710 retired for the sibling #4463 gate; the two doors now read one declared key.
8+
9+
Behaviour change for self-hosted deployments: an object write whose `externalSharingModel` is wider than its `sharingModel` — or an environment overlay that widens a packaged object's OWD — is now refused with `403` (`owd_external_wider` / `owd_widening_forbidden`) on the draft path, the active path and package authoring, instead of being accepted. Fix the posture in the object definition; widening a packaged object legitimately is authored in the package source and published (ADR-0090 D7). A kernel that declares `authoringChannel: 'package-author'` is unaffected — package authoring stays gated at build time by `validateSecurityPosture`.

packages/metadata-protocol/src/mutation-listeners.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,14 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)
119119

120120
// #3050 — the pre-persistence AUTHORING GATE seam (ADR-0094 addendum). The
121121
// inverse contract of the projector: it runs BEFORE persistence and a throw
122-
// PROPAGATES (rejecting the write) instead of being swallowed. saveMetaItem
123-
// invokes it for env writes only, both draft and publish-mode saves; the
124-
// domain-gate behavior itself (OWD posture) is pinned in plugin-security's
125-
// object-posture-gate suite.
122+
// PROPAGATES (rejecting the write) instead of being swallowed. [#7674]
123+
// saveMetaItem invokes it on every channel except a declared `package-author`
124+
// one — both draft and publish-mode saves; it used to say "for env writes
125+
// only", which was the `environmentId` proxy that left the gate dead on every
126+
// host-config deployment. The domain-gate behavior itself (OWD posture) is
127+
// pinned in plugin-security's object-posture-gate suite, and its journey
128+
// through the real `PUT /api/v1/meta/object/*` in
129+
// `packages/rest/src/meta-object-owd-gate.test.ts`.
126130
describe('ObjectStackProtocolImplementation.registerAuthoringGate (#3050)', () => {
127131
const save = (over: Record<string, unknown> = {}) => ({
128132
type: 'object', name: 'crm_account', state: 'active' as const, body: { sharingModel: 'private' }, ...over,

packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -497,27 +497,73 @@ describe('#6710 — gate activation is keyed on the declared authoring channel',
497497
expect(shouted[0]).toContain('approval-expression-invalid');
498498
});
499499

500-
it('does not disturb the OTHER gates that legitimately read environmentId', async () => {
501-
// #6710 re-keys ONE activation. The #3050 authoring gate keeps its own
502-
// `environmentId !== undefined` scope check, and it must stay keyed
503-
// there — that gate really is about row scope. Declaring the
504-
// package-author channel must not switch it on for a control-plane
505-
// kernel, and must not switch it off for a tenant one.
500+
// [#7674] REPLACED, not re-spelled. The case that stood here asserted the
501+
// opposite invariant — "the #3050 authoring gate keeps its own
502+
// `environmentId !== undefined` scope check, and it must stay keyed there"
503+
// — and that sentence was the defect, written down as a pin. #6710 retired
504+
// the proxy for the #4463 gate and left its sibling on it, so the ADR-0090
505+
// D11 object posture gate (`owd_widening_forbidden` / `owd_external_wider`)
506+
// ran on NO host-config deployment: `new ObjectQLPlugin()` leaves
507+
// `environmentId` undefined and serves an end-user `PUT /api/v1/meta/*`.
508+
// The old case could not see that, because it drove the control-plane row
509+
// (undefined) only through the `package-author` channel — the one column
510+
// where both keys agree.
511+
//
512+
// The four-cell matrix below is what makes the two keys distinguishable.
513+
// Note the one cell whose verdict FLIPS: `('env_test', 'package-author')`
514+
// was gated and is not any more. That is #6710's direction applied
515+
// honestly rather than half-applied — a kernel that claims to BE the
516+
// package author is treated as one by both doors, and package authoring is
517+
// gated at build time instead (`validateSecurityPosture` is `CLI_ONLY` in
518+
// `AUTHORING_RULES`, and R1's own message prescribes exactly that route:
519+
// "widen it in the package source and publish through the package
520+
// pipeline"). No assembly in this repo declares that channel today; only
521+
// the genuine control plane may.
522+
it.each([
523+
{ envId: undefined, channel: undefined, gated: true, why: 'THE DEFECT: the host-config assembler — `new ObjectQLPlugin()`, no environment id, undeclared channel ⇒ the fail-safe default' },
524+
{ envId: 'env_test', channel: undefined, gated: true, why: 'the ordinary tenant kernel, unchanged' },
525+
{ envId: undefined, channel: 'package-author' as const, gated: false, why: 'the genuine control-plane bootstrap kernel' },
526+
{ envId: 'env_test', channel: 'package-author' as const, gated: false, why: 'a declared package author that also carries a row scope — the cell that flips' },
527+
])('#3050 gate: environmentId=$envId channel=$channel ⇒ gated=$gated ($why)', async ({ envId, channel, gated }) => {
506528
const seen: string[] = [];
507-
const gate = (ctx: { type: string; name: string }) => { seen.push(`${ctx.type}/${ctx.name}`); };
529+
const { protocol } = makeProtocolOn(envId, channel);
530+
protocol.registerAuthoringGate('flow', (ctx: { type: string; name: string }) => {
531+
seen.push(`${ctx.type}/${ctx.name}`);
532+
});
508533

509-
const cp = makeProtocolOn(undefined, 'package-author');
510-
cp.protocol.registerAuthoringGate('flow', gate);
511-
await cp.protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: validApprovalFlow() });
512-
expect(seen, 'the #3050 gate stays OFF where environmentId is undefined').toEqual([]);
534+
// A body the #4463 rules ACCEPT, so what this matrix measures is the
535+
// #3050 dispatch alone: a broken body would be refused upstream on the
536+
// two `'environment'` rows and the gate would never be reached, which
537+
// would make the two keys look identical again.
538+
await protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: validApprovalFlow() });
513539

514-
const tenant = makeProtocolOn('env_test', 'package-author');
515-
tenant.protocol.registerAuthoringGate('flow', gate);
516-
await tenant.protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: brokenApprovalFlow() });
517-
expect(
518-
seen,
519-
'the #3050 gate stays ON where environmentId is set, even though the '
520-
+ '#4463 gate was waived by the channel declaration — two gates, two keys',
521-
).toEqual(['flow/leave_approval']);
540+
expect(seen).toEqual(gated ? ['flow/leave_approval'] : []);
541+
});
542+
543+
it('the #3050 gate and the #4463 gate now read ONE key, and `environmentId` keeps only row scope', async () => {
544+
// The positive statement of the matrix above: the two doors that ask
545+
// "is this an author publishing?" can no longer disagree, which is the
546+
// property whose absence let #7674 outlive #6710 by one gate.
547+
const seen: string[] = [];
548+
const gate = (ctx: { type: string; name: string }) => { seen.push(`${ctx.type}/${ctx.name}`); };
549+
550+
// Host config: #4463 refuses the broken body (422) AND #3050 would have
551+
// run — the write never reaches persistence either way, and both gates
552+
// are live on the topology that had neither.
553+
const host = makeProtocolOn(undefined);
554+
host.protocol.registerAuthoringGate('flow', gate);
555+
const err = await host.protocol
556+
.saveMetaItem({ type: 'flow', name: 'leave_approval', item: brokenApprovalFlow() })
557+
.catch((e: any) => e);
558+
expect(err.status).toBe(422);
559+
expect(err.code).toBe('INVALID_METADATA');
560+
expect(flowRows(host.rows), 'refused before persistence').toEqual([]);
561+
562+
// …and the same host config, given a body the rules accept, runs the
563+
// #3050 gate and stores the row. "Gated" must not mean "refuses
564+
// everything".
565+
await host.protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: validApprovalFlow() });
566+
expect(seen).toEqual(['flow/leave_approval']);
567+
expect(flowRows(host.rows)).toHaveLength(1);
522568
});
523569
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2740,9 +2740,16 @@ export class ObjectStackProtocolImplementation implements
27402740
*
27412741
* [#6710] Row scoping ONLY. This key keeps every one of its other jobs —
27422742
* the `environment_id` stamp/filter, the ADR-0005 overlay-whitelist gate,
2743-
* the #3050 authoring-gate scope, the local metadata-storage provisioning
2744-
* decision — but it no longer decides whether the #4463 runtime authoring
2745-
* rules run. See {@link authoringChannel}.
2743+
* the local metadata-storage provisioning decision — but it no longer
2744+
* decides whether the #4463 runtime authoring rules run.
2745+
* See {@link authoringChannel}.
2746+
*
2747+
* [#7674] …and no longer whether the #3050 pre-persistence authoring gate
2748+
* runs either. The sentence above used to list "the #3050 authoring-gate
2749+
* scope" among this key's surviving jobs, and that call site kept the
2750+
* `environmentId !== undefined` wrapper #6710 had just retired next door —
2751+
* so the identical proxy-signal defect outlived its own diagnosis on the
2752+
* sibling gate. Both doors read {@link authoringChannel} now.
27462753
*/
27472754
private environmentId?: string;
27482755

@@ -2751,10 +2758,12 @@ export class ObjectStackProtocolImplementation implements
27512758
* which is what makes the #4463 gate active on every kernel that does not
27522759
* explicitly claim to be the package author's own bootstrap channel.
27532760
*
2754-
* Read by {@link assertRuntimeAuthoringRules} and nothing else — this is
2755-
* deliberately NOT a general-purpose authorization key. The ADR-0005
2756-
* overlay gate and the #3050 authoring gate keep reading `environmentId`,
2757-
* because those two really are about row scope.
2761+
* Read by {@link assertRuntimeAuthoringRules} and, since #7674, by the
2762+
* #3050 pre-persistence authoring gate's call site in {@link saveMetaItem}
2763+
* — the two doors that ask "is this an AUTHOR publishing, or the package
2764+
* author's own bootstrap?". It is deliberately NOT a general-purpose
2765+
* authorization key: the ADR-0005 overlay-whitelist gate keeps reading
2766+
* `environmentId`, because that one really is about row scope.
27582767
*/
27592768
private authoringChannel: MetadataAuthoringChannel;
27602769

@@ -3089,8 +3098,12 @@ export class ObjectStackProtocolImplementation implements
30893098
// mechanism, because the failure mode being designed out is precisely
30903099
// "a new assembly variant nobody thought about".
30913100
//
3092-
// `environmentId` keeps every other job it has, including the #3050
3093-
// authoring gate's own scope check below.
3101+
// `environmentId` keeps its row-scoping jobs — the `environment_id`
3102+
// stamp/filter and the ADR-0005 overlay-whitelist gate. [#7674] It no
3103+
// longer keys the #3050 authoring gate below either: #6710 re-keyed
3104+
// this activation and left that one on the retired proxy, which cost
3105+
// the ADR-0090 D11 object posture gate every host-config deployment
3106+
// until #7674 finished the move.
30943107
if (this.authoringChannel === 'package-author') return [];
30953108
if (evt.state !== 'active') return [];
30963109
// `os migrate meta --stored --apply` rewrites rows that ALREADY EXIST
@@ -10104,10 +10117,32 @@ export class ObjectStackProtocolImplementation implements
1010410117
// Pre-persistence authoring gate (#3050): a domain plugin may veto the
1010510118
// body before it persists (throws propagate to the caller with their
1010610119
// status/code). Runs for BOTH draft and publish-mode saves, so a later
10107-
// publishMetaItem promotes an already-gated body. Environment writes
10108-
// only — control-plane bootstrap writes (environmentId undefined) are
10109-
// the package author's own channel, mirroring the ADR-0005 gate above.
10110-
if (this.environmentId !== undefined) {
10120+
// publishMetaItem promotes an already-gated body.
10121+
//
10122+
// [#7674] Keyed on the DECLARED authoring channel, exactly as #6710
10123+
// re-keyed `assertRuntimeAuthoringRules` a few hundred lines up. This
10124+
// line used to read `if (this.environmentId !== undefined)`, and its
10125+
// own comment reaffirmed the reasoning #6710 had already retired:
10126+
// "control-plane bootstrap writes (environmentId undefined) are the
10127+
// package author's own channel". They are not the only such writes.
10128+
// The CLI's lightweight host-config assembler (`serve.ts`'s
10129+
// `config.objects && !hasObjectQL` branch → `new ObjectQLPlugin()`
10130+
// with no options) leaves `environmentId` undefined too, and it serves
10131+
// an END-USER `PUT /api/v1/meta/*` — `isHostConfig` →
10132+
// `shouldBootWithLibrary === false` is the flagship showcase's own boot
10133+
// shape. So plugin-security's ADR-0090 D11 object posture gate — R1
10134+
// `owd_widening_forbidden` and R2 `owd_external_wider` — ran on NO
10135+
// self-hosted deployment at all, while `AUTHORING_RULES` deliberately
10136+
// withheld its own `validateSecurityPosture` from the runtime surface
10137+
// on the stated grounds that this gate already covered it
10138+
// (`packages/lint/src/authoring-rules.ts`, `surfaceReason`). Declared,
10139+
// not enforced, on both tables at once.
10140+
//
10141+
// The direction is #6710's and matters more than the mechanism: the
10142+
// DEFAULT is the gated one, so an assembly variant nobody has thought
10143+
// of yet gets more enforcement, never less. Only a caller that claims
10144+
// to BE the package author is treated as one.
10145+
if (this.authoringChannel !== 'package-author') {
1011110146
await this.runAuthoringGate({
1011210147
type: request.type,
1011310148
name: request.name,

packages/rest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@objectstack/metadata": "workspace:*",
3636
"@objectstack/metadata-protocol": "workspace:*",
3737
"@objectstack/objectql": "workspace:*",
38+
"@objectstack/plugin-security": "workspace:*",
3839
"@objectstack/service-analytics": "workspace:*",
3940
"@types/node": "^26.1.2",
4041
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)