Skip to content

Commit c2899d1

Browse files
committed
feat(security/metadata-protocol): enforce OWD posture on the runtime write path (#3050)
Implements the ADR-0094-addendum registerAuthoringGate seam — an awaited, THROWING pre-persistence hook in saveMetaItem (draft + publish-mode saves, environment writes only; control-plane bootstrap bypasses like the ADR-0005 gate) — and registers plugin-security's object posture gate on it: - R1 env-tighten-only (ADR-0086 D1, ADR-0049): an environment overlay of a packaged object may not widen sharingModel/externalSharingModel beyond the packaged declaration. Closes the OS_METADATA_WRITABLE=object hole where the escape hatch admitted unvalidated OWD widening (private -> public_read_write) with zero checks. - R2 external<=internal (ADR-0090 D11): previously .describe() prose + CLI lint only; now rejected at save time on every runtime authoring surface. Write-path only: no zod refine, stored/grandfathered metadata keeps loading (the ADR-0090 D1 lesson). controlled_by_parent excluded from ordering on either side, mirroring lint's OWD_WIDTH. publishMetaItem promotes an already-gated draft body, so no second gate. Protocols predating the seam keep legacy behavior (feature-detected wiring). Tests: authoring-gate seam contract (metadata-protocol, 6 new) + posture gate unit suite (plugin-security, 18).
1 parent aaec5db commit c2899d1

8 files changed

Lines changed: 485 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
OWD posture is now enforced on the runtime write path (#3050). `metadata-protocol` gains the ADR-0094-addendum `registerAuthoringGate(type, gate)` seam — an awaited, throwing pre-persistence hook inside `saveMetaItem` (draft and publish-mode saves; environment writes only). `plugin-security` registers the `object` posture gate on it: an environment overlay of a packaged object may only TIGHTEN `sharingModel`/`externalSharingModel` (ADR-0086 D1 — closes the `OS_METADATA_WRITABLE=object` unvalidated-widening hole), and `externalSharingModel ≤ sharingModel` (ADR-0090 D11) is now rejected at save time instead of only by CLI lint. Write-path only — stored metadata keeps loading unchanged.

packages/metadata-protocol/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js';
44
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
55
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
6+
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';
67

78
export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
89
export type {

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,73 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)
116116
expect(out).toEqual({ success: false, error: 'projection boom' });
117117
});
118118
});
119+
120+
// #3050 — the pre-persistence AUTHORING GATE seam (ADR-0094 addendum). The
121+
// 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.
126+
describe('ObjectStackProtocolImplementation.registerAuthoringGate (#3050)', () => {
127+
const save = (over: Record<string, unknown> = {}) => ({
128+
type: 'object', name: 'crm_account', state: 'active' as const, body: { sharingModel: 'private' }, ...over,
129+
});
130+
131+
it('dispatches the registered gate with the body and state', async () => {
132+
const p = makeProtocol();
133+
const seen: any[] = [];
134+
p.registerAuthoringGate('object', (ctx) => { seen.push(ctx); });
135+
await (p as any).runAuthoringGate(save({ state: 'draft' }));
136+
expect(seen).toHaveLength(1);
137+
expect(seen[0]).toMatchObject({
138+
type: 'object', name: 'crm_account', state: 'draft',
139+
body: { sharingModel: 'private' }, isArtifactBacked: false,
140+
});
141+
});
142+
143+
it('normalizes plural registrations to the singular type', async () => {
144+
const p = makeProtocol();
145+
const gate = vi.fn();
146+
p.registerAuthoringGate('objects', gate);
147+
await (p as any).runAuthoringGate(save());
148+
expect(gate).toHaveBeenCalledTimes(1);
149+
});
150+
151+
it('a gate throw PROPAGATES with its status/code (the write is rejected)', async () => {
152+
const p = makeProtocol();
153+
p.registerAuthoringGate('object', () => {
154+
const err: any = new Error('[owd_widening_forbidden] no widening');
155+
err.code = 'owd_widening_forbidden'; err.status = 403;
156+
throw err;
157+
});
158+
await expect((p as any).runAuthoringGate(save())).rejects.toMatchObject({
159+
code: 'owd_widening_forbidden', status: 403,
160+
});
161+
});
162+
163+
it('is a no-op for types with no registered gate', async () => {
164+
const p = makeProtocol();
165+
await expect((p as any).runAuthoringGate(save({ type: 'view' }))).resolves.toBeUndefined();
166+
});
167+
168+
it('resolves isArtifactBacked + declaredBody from the artifact registry', async () => {
169+
const declared = { name: 'crm_account', sharingModel: 'private', _packageId: 'crm' };
170+
const engine = { registry: { getItem: (_t: string, n: string) => (n === 'crm_account' ? declared : undefined) } };
171+
const p = new ObjectStackProtocolImplementation(engine as any);
172+
const seen: any[] = [];
173+
p.registerAuthoringGate('object', (ctx) => { seen.push(ctx); });
174+
await (p as any).runAuthoringGate(save());
175+
expect(seen[0].isArtifactBacked).toBe(true);
176+
expect(seen[0].declaredBody).toBe(declared);
177+
});
178+
179+
it('a second registration replaces the first (idempotent re-init)', async () => {
180+
const p = makeProtocol();
181+
const first = vi.fn(); const second = vi.fn();
182+
p.registerAuthoringGate('object', first);
183+
p.registerAuthoringGate('object', second);
184+
await (p as any).runAuthoringGate(save());
185+
expect(first).not.toHaveBeenCalled();
186+
expect(second).toHaveBeenCalledTimes(1);
187+
});
188+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,40 @@ export interface MutationProjectionOutcome {
799799
error?: string;
800800
}
801801

802+
/**
803+
* Pre-persistence authoring gate (ADR-0094 addendum seam; #3050).
804+
*
805+
* Unlike the post-persist {@link MetadataMutationProjector} (best-effort,
806+
* never thrown), an authoring gate runs BEFORE persistence and REJECTS the
807+
* write by throwing — it is the seam for domain invariants that must hold on
808+
* every runtime-authored body regardless of which HTTP surface produced it
809+
* (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN
810+
* a packaged object's `sharingModel`, and `externalSharingModel ≤
811+
* sharingModel` per ADR-0090 D11).
812+
*
813+
* Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after
814+
* the ADR-0005 overlay/runtime-create authorization and the per-type spec
815+
* validation — so `publishMetaItem` promotes an already-gated body and needs
816+
* no second gate. Environment writes only: control-plane bootstrap writes
817+
* (`environmentId === undefined`) are the package author's own channel and
818+
* bypass the gate, mirroring the ADR-0005 gate above.
819+
*/
820+
export interface MetadataAuthoringGateContext {
821+
/** Singular type name (e.g. `object`). */
822+
type: string;
823+
name: string;
824+
/** Lifecycle the body is being saved into. */
825+
state: 'draft' | 'active';
826+
organizationId?: string;
827+
/** The body being persisted. */
828+
body: unknown;
829+
/** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */
830+
isArtifactBacked: boolean;
831+
/** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */
832+
declaredBody?: unknown;
833+
}
834+
export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise<void>;
835+
802836
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
803837
private engine: MetadataHostEngine;
804838
private getServicesRegistry?: () => Map<string, any>;
@@ -847,6 +881,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
847881
*/
848882
private mutationProjectors = new Map<string, MetadataMutationProjector>();
849883

884+
/**
885+
* Pre-persistence authoring gates (#3050). One per type; a second
886+
* registration replaces the first (idempotent re-init). Unlike
887+
* projectors these THROW to reject the write — see
888+
* {@link MetadataAuthoringGate}.
889+
*/
890+
private authoringGates = new Map<string, MetadataAuthoringGate>();
891+
850892
constructor(
851893
engine: IDataEngine,
852894
getServicesRegistry?: () => Map<string, any>,
@@ -894,6 +936,49 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
894936
this.mutationProjectors.set(singular, projector);
895937
}
896938

939+
/**
940+
* Register the pre-persistence authoring gate for a metadata type
941+
* (ADR-0094 addendum seam; #3050). Called by domain plugins at init —
942+
* e.g. plugin-security registers the `object` OWD posture gate. The gate
943+
* THROWS to reject the write. Singular or plural type names both resolve;
944+
* one gate per type, a second registration replaces the first.
945+
*/
946+
registerAuthoringGate(type: string, gate: MetadataAuthoringGate): void {
947+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
948+
this.authoringGates.set(singular, gate);
949+
}
950+
951+
/**
952+
* Run the registered authoring gate for an about-to-persist body (#3050).
953+
* No-op when no gate is registered for the type. A gate throw PROPAGATES
954+
* (with its status/code) — that is the contract: the write is rejected
955+
* before persistence. Resolves the artifact-backed flag and the packaged
956+
* declaration body (the baseline an overlay customizes) for the gate.
957+
*/
958+
private async runAuthoringGate(evt: {
959+
type: string; name: string; state: 'draft' | 'active'; organizationId?: string; body: unknown;
960+
}): Promise<void> {
961+
const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type;
962+
const gate = this.authoringGates.get(singular);
963+
if (!gate) return;
964+
const artifactBacked = this.isArtifactBacked(evt.type, evt.name);
965+
let declaredBody: unknown;
966+
if (artifactBacked && typeof this.engine.registry?.getItem === 'function') {
967+
const alt = PLURAL_TO_SINGULAR[evt.type] ?? SINGULAR_TO_PLURAL[evt.type];
968+
declaredBody = this.engine.registry.getItem(evt.type, evt.name)
969+
?? (alt ? this.engine.registry.getItem(alt, evt.name) : undefined);
970+
}
971+
await gate({
972+
type: singular,
973+
name: evt.name,
974+
state: evt.state,
975+
...(evt.organizationId ? { organizationId: evt.organizationId } : {}),
976+
body: evt.body,
977+
isArtifactBacked: artifactBacked,
978+
...(declaredBody !== undefined ? { declaredBody } : {}),
979+
});
980+
}
981+
897982
/**
898983
* Run the registered projector for a just-persisted mutation (ADR-0094).
899984
* Returns `undefined` when no projector is registered for the type;
@@ -4010,6 +4095,22 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
40104095
}
40114096
}
40124097

4098+
// Pre-persistence authoring gate (#3050): a domain plugin may veto the
4099+
// body before it persists (throws propagate to the caller with their
4100+
// status/code). Runs for BOTH draft and publish-mode saves, so a later
4101+
// publishMetaItem promotes an already-gated body. Environment writes
4102+
// only — control-plane bootstrap writes (environmentId undefined) are
4103+
// the package author's own channel, mirroring the ADR-0005 gate above.
4104+
if (this.environmentId !== undefined) {
4105+
await this.runAuthoringGate({
4106+
type: request.type,
4107+
name: request.name,
4108+
state: mode === 'draft' ? 'draft' : 'active',
4109+
...(request.organizationId ? { organizationId: request.organizationId } : {}),
4110+
body: request.item,
4111+
});
4112+
}
4113+
40134114
// 1. Update the in-memory registry (runtime cache) ONLY for the
40144115
// `object` type — schema definitions feed engine.syncSchema and
40154116
// must be reflected immediately for CRUD to work. For all other

packages/plugins/plugin-security/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type {
4444
} from './permission-set-projection.js';
4545
export { cleanupPackagePermissions } from './cleanup-package-permissions.js';
4646
export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js';
47+
export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js';
48+
export type { ObjectPostureGateContext } from './object-posture-gate.js';
4749
export { claimSeedOwnership } from './claim-seed-ownership.js';
4850
export { normalizeManagedByVocab } from './normalize-managed-by.js';
4951
export { appDefaultPermissionSetName } from './app-default-permission-set.js';
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #3050 — OWD posture authoring gate: env-tighten-only over packaged
4+
// declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11), enforced
5+
// on the runtime write path (previously CLI-lint-only).
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js';
9+
10+
const base = (over: Partial<Parameters<typeof objectPostureGate>[0]> = {}) => ({
11+
type: 'object',
12+
name: 'crm_account',
13+
body: {},
14+
isArtifactBacked: false,
15+
...over,
16+
});
17+
18+
describe('R2 — external ≤ internal (ADR-0090 D11)', () => {
19+
it('rejects external wider than internal', () => {
20+
expect(() => objectPostureGate(base({
21+
body: { sharingModel: 'public_read', externalSharingModel: 'public_read_write' },
22+
}))).toThrowError(/owd_external_wider/);
23+
});
24+
25+
it('rejects explicit external on an OWD-less body (internal defaults to private, ADR-0090 D1)', () => {
26+
expect(() => objectPostureGate(base({
27+
body: { externalSharingModel: 'public_read' },
28+
}))).toThrowError(/owd_external_wider/);
29+
});
30+
31+
it('accepts external equal to internal', () => {
32+
expect(() => objectPostureGate(base({
33+
body: { sharingModel: 'public_read', externalSharingModel: 'public_read' },
34+
}))).not.toThrow();
35+
});
36+
37+
it('accepts external tighter than internal', () => {
38+
expect(() => objectPostureGate(base({
39+
body: { sharingModel: 'public_read_write', externalSharingModel: 'private' },
40+
}))).not.toThrow();
41+
});
42+
43+
it('skips ordering when either side is controlled_by_parent (inherits master pair)', () => {
44+
expect(() => objectPostureGate(base({
45+
body: { sharingModel: 'controlled_by_parent', externalSharingModel: 'public_read' },
46+
}))).not.toThrow();
47+
expect(() => objectPostureGate(base({
48+
body: { sharingModel: 'public_read', externalSharingModel: 'controlled_by_parent' },
49+
}))).not.toThrow();
50+
});
51+
52+
it('accepts a body with no posture fields at all', () => {
53+
expect(() => objectPostureGate(base({ body: { name: 'crm_account', fields: {} } }))).not.toThrow();
54+
});
55+
56+
it('carries 403 + code on the error', () => {
57+
try {
58+
objectPostureGate(base({ body: { sharingModel: 'private', externalSharingModel: 'public_read' } }));
59+
expect.unreachable('should have thrown');
60+
} catch (e: any) {
61+
expect(e.status).toBe(403);
62+
expect(e.code).toBe('owd_external_wider');
63+
}
64+
});
65+
});
66+
67+
describe('R1 — env-tighten-only over a packaged declaration (ADR-0086 D1)', () => {
68+
it('rejects widening internal beyond the declared baseline', () => {
69+
expect(() => objectPostureGate(base({
70+
isArtifactBacked: true,
71+
declaredBody: { sharingModel: 'private' },
72+
body: { sharingModel: 'public_read_write' },
73+
}))).toThrowError(/owd_widening_forbidden/);
74+
});
75+
76+
it('rejects widening when the declaration is OWD-less (baseline = private per ADR-0090 D1)', () => {
77+
expect(() => objectPostureGate(base({
78+
isArtifactBacked: true,
79+
declaredBody: { name: 'crm_account' },
80+
body: { sharingModel: 'public_read' },
81+
}))).toThrowError(/owd_widening_forbidden/);
82+
});
83+
84+
it('rejects widening external beyond the declared external (default private, D11)', () => {
85+
expect(() => objectPostureGate(base({
86+
isArtifactBacked: true,
87+
declaredBody: { sharingModel: 'public_read' },
88+
body: { sharingModel: 'public_read', externalSharingModel: 'public_read' },
89+
}))).toThrowError(/owd_widening_forbidden/);
90+
});
91+
92+
it('accepts tightening the packaged posture', () => {
93+
expect(() => objectPostureGate(base({
94+
isArtifactBacked: true,
95+
declaredBody: { sharingModel: 'public_read_write', externalSharingModel: 'public_read' },
96+
body: { sharingModel: 'private', externalSharingModel: 'private' },
97+
}))).not.toThrow();
98+
});
99+
100+
it('accepts an overlay that leaves posture unchanged', () => {
101+
expect(() => objectPostureGate(base({
102+
isArtifactBacked: true,
103+
declaredBody: { sharingModel: 'public_read' },
104+
body: { sharingModel: 'public_read', label: 'Renamed' },
105+
}))).not.toThrow();
106+
});
107+
108+
it('accepts an overlay that omits posture fields entirely', () => {
109+
expect(() => objectPostureGate(base({
110+
isArtifactBacked: true,
111+
declaredBody: { sharingModel: 'private' },
112+
body: { label: 'Renamed' },
113+
}))).not.toThrow();
114+
});
115+
116+
it('skips tighten comparison when declared side is controlled_by_parent', () => {
117+
expect(() => objectPostureGate(base({
118+
isArtifactBacked: true,
119+
declaredBody: { sharingModel: 'controlled_by_parent' },
120+
body: { sharingModel: 'public_read' },
121+
}))).not.toThrow();
122+
});
123+
124+
it('does not apply R1 to runtime-created (non-artifact) objects — env owns them', () => {
125+
expect(() => objectPostureGate(base({
126+
isArtifactBacked: false,
127+
body: { sharingModel: 'public_read_write' },
128+
}))).not.toThrow();
129+
});
130+
131+
it('does not apply R1 when the declared baseline is unavailable', () => {
132+
expect(() => objectPostureGate(base({
133+
isArtifactBacked: true,
134+
body: { sharingModel: 'public_read_write' },
135+
}))).not.toThrow();
136+
});
137+
});
138+
139+
describe('registerObjectPostureGate wiring', () => {
140+
it('registers on a protocol exposing registerAuthoringGate and rejects through it', async () => {
141+
const gates = new Map<string, (ctx: any) => void | Promise<void>>();
142+
const protocol = {
143+
registerAuthoringGate: (type: string, gate: (ctx: any) => void) => gates.set(type, gate),
144+
};
145+
expect(registerObjectPostureGate(protocol)).toBe(true);
146+
const gate = gates.get('object')!;
147+
expect(gate).toBeTypeOf('function');
148+
await expect(async () => gate({
149+
type: 'object', name: 'crm_account', body: { sharingModel: 'private', externalSharingModel: 'public_read' },
150+
isArtifactBacked: false,
151+
})).rejects.toThrowError(/owd_external_wider/);
152+
});
153+
154+
it('feature-detects: returns false on a protocol without the seam', () => {
155+
expect(registerObjectPostureGate({})).toBe(false);
156+
expect(registerObjectPostureGate(undefined)).toBe(false);
157+
});
158+
});

0 commit comments

Comments
 (0)