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
9 changes: 9 additions & 0 deletions .changeset/owd-authoring-gate-authoring-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@objectstack/metadata-protocol": patch
---

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.

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.

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`.
12 changes: 8 additions & 4 deletions packages/metadata-protocol/src/mutation-listeners.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,10 +119,14 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)

// #3050 — the pre-persistence AUTHORING GATE seam (ADR-0094 addendum). The
// inverse contract of the projector: it runs BEFORE persistence and a throw
// PROPAGATES (rejecting the write) instead of being swallowed. saveMetaItem
// invokes it for env writes only, both draft and publish-mode saves; the
// domain-gate behavior itself (OWD posture) is pinned in plugin-security's
// object-posture-gate suite.
// PROPAGATES (rejecting the write) instead of being swallowed. [#7674]
// saveMetaItem invokes it on every channel except a declared `package-author`
// one — both draft and publish-mode saves; it used to say "for env writes
// only", which was the `environmentId` proxy that left the gate dead on every
// host-config deployment. The domain-gate behavior itself (OWD posture) is
// pinned in plugin-security's object-posture-gate suite, and its journey
// through the real `PUT /api/v1/meta/object/*` in
// `packages/rest/src/meta-object-owd-gate.test.ts`.
describe('ObjectStackProtocolImplementation.registerAuthoringGate (#3050)', () => {
const save = (over: Record<string, unknown> = {}) => ({
type: 'object', name: 'crm_account', state: 'active' as const, body: { sharingModel: 'private' }, ...over,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -497,27 +497,73 @@ describe('#6710 — gate activation is keyed on the declared authoring channel',
expect(shouted[0]).toContain('approval-expression-invalid');
});

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

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

const tenant = makeProtocolOn('env_test', 'package-author');
tenant.protocol.registerAuthoringGate('flow', gate);
await tenant.protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: brokenApprovalFlow() });
expect(
seen,
'the #3050 gate stays ON where environmentId is set, even though the '
+ '#4463 gate was waived by the channel declaration — two gates, two keys',
).toEqual(['flow/leave_approval']);
expect(seen).toEqual(gated ? ['flow/leave_approval'] : []);
});

it('the #3050 gate and the #4463 gate now read ONE key, and `environmentId` keeps only row scope', async () => {
// The positive statement of the matrix above: the two doors that ask
// "is this an author publishing?" can no longer disagree, which is the
// property whose absence let #7674 outlive #6710 by one gate.
const seen: string[] = [];
const gate = (ctx: { type: string; name: string }) => { seen.push(`${ctx.type}/${ctx.name}`); };

// Host config: #4463 refuses the broken body (422) AND #3050 would have
// run — the write never reaches persistence either way, and both gates
// are live on the topology that had neither.
const host = makeProtocolOn(undefined);
host.protocol.registerAuthoringGate('flow', gate);
const err = await host.protocol
.saveMetaItem({ type: 'flow', name: 'leave_approval', item: brokenApprovalFlow() })
.catch((e: any) => e);
expect(err.status).toBe(422);
expect(err.code).toBe('INVALID_METADATA');
expect(flowRows(host.rows), 'refused before persistence').toEqual([]);

// …and the same host config, given a body the rules accept, runs the
// #3050 gate and stores the row. "Gated" must not mean "refuses
// everything".
await host.protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item: validApprovalFlow() });
expect(seen).toEqual(['flow/leave_approval']);
expect(flowRows(host.rows)).toHaveLength(1);
});
});
61 changes: 48 additions & 13 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2740,9 +2740,16 @@ export class ObjectStackProtocolImplementation implements
*
* [#6710] Row scoping ONLY. This key keeps every one of its other jobs —
* the `environment_id` stamp/filter, the ADR-0005 overlay-whitelist gate,
* the #3050 authoring-gate scope, the local metadata-storage provisioning
* decision — but it no longer decides whether the #4463 runtime authoring
* rules run. See {@link authoringChannel}.
* the local metadata-storage provisioning decision — but it no longer
* decides whether the #4463 runtime authoring rules run.
* See {@link authoringChannel}.
*
* [#7674] …and no longer whether the #3050 pre-persistence authoring gate
* runs either. The sentence above used to list "the #3050 authoring-gate
* scope" among this key's surviving jobs, and that call site kept the
* `environmentId !== undefined` wrapper #6710 had just retired next door —
* so the identical proxy-signal defect outlived its own diagnosis on the
* sibling gate. Both doors read {@link authoringChannel} now.
*/
private environmentId?: string;

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

Expand DownExpand Up@@ -3089,8 +3098,12 @@ export class ObjectStackProtocolImplementation implements
// mechanism, because the failure mode being designed out is precisely
// "a new assembly variant nobody thought about".
//
// `environmentId` keeps every other job it has, including the #3050
// authoring gate's own scope check below.
// `environmentId` keeps its row-scoping jobs — the `environment_id`
// stamp/filter and the ADR-0005 overlay-whitelist gate. [#7674] It no
// longer keys the #3050 authoring gate below either: #6710 re-keyed
// this activation and left that one on the retired proxy, which cost
// the ADR-0090 D11 object posture gate every host-config deployment
// until #7674 finished the move.
if (this.authoringChannel === 'package-author') return [];
if (evt.state !== 'active') return [];
// `os migrate meta --stored --apply` rewrites rows that ALREADY EXIST
Expand DownExpand Up@@ -10052,10 +10065,32 @@ export class ObjectStackProtocolImplementation implements
// Pre-persistence authoring gate (#3050): a domain plugin may veto the
// body before it persists (throws propagate to the caller with their
// status/code). Runs for BOTH draft and publish-mode saves, so a later
// publishMetaItem promotes an already-gated body. Environment writes
// only — control-plane bootstrap writes (environmentId undefined) are
// the package author's own channel, mirroring the ADR-0005 gate above.
if (this.environmentId !== undefined) {
// publishMetaItem promotes an already-gated body.
//
// [#7674] Keyed on the DECLARED authoring channel, exactly as #6710
// re-keyed `assertRuntimeAuthoringRules` a few hundred lines up. This
// line used to read `if (this.environmentId !== undefined)`, and its
// own comment reaffirmed the reasoning #6710 had already retired:
// "control-plane bootstrap writes (environmentId undefined) are the
// package author's own channel". They are not the only such writes.
// The CLI's lightweight host-config assembler (`serve.ts`'s
// `config.objects && !hasObjectQL` branch → `new ObjectQLPlugin()`
// with no options) leaves `environmentId` undefined too, and it serves
// an END-USER `PUT /api/v1/meta/*` — `isHostConfig` →
// `shouldBootWithLibrary === false` is the flagship showcase's own boot
// shape. So plugin-security's ADR-0090 D11 object posture gate — R1
// `owd_widening_forbidden` and R2 `owd_external_wider` — ran on NO
// self-hosted deployment at all, while `AUTHORING_RULES` deliberately
// withheld its own `validateSecurityPosture` from the runtime surface
// on the stated grounds that this gate already covered it
// (`packages/lint/src/authoring-rules.ts`, `surfaceReason`). Declared,
// not enforced, on both tables at once.
//
// The direction is #6710's and matters more than the mechanism: the
// DEFAULT is the gated one, so an assembly variant nobody has thought
// of yet gets more enforcement, never less. Only a caller that claims
// to BE the package author is treated as one.
if (this.authoringChannel !== 'package-author') {
await this.runAuthoringGate({
type: request.type,
name: request.name,
Expand Down
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/service-analytics": "workspace:*",
"@types/node": "^26.1.2",
"typescript": "^6.0.3",
Expand Down
Loading
Loading