From c2899d1d0e1156dc2eef46848574c4da7435dcd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:47:16 +0000 Subject: [PATCH 1/2] feat(security/metadata-protocol): enforce OWD posture on the runtime write path (#3050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .changeset/owd-posture-authoring-gate.md | 6 + packages/metadata-protocol/src/index.ts | 1 + .../src/mutation-listeners.test.ts | 70 ++++++++ packages/metadata-protocol/src/protocol.ts | 101 +++++++++++ packages/plugins/plugin-security/src/index.ts | 2 + .../src/object-posture-gate.test.ts | 158 ++++++++++++++++++ .../src/object-posture-gate.ts | 140 ++++++++++++++++ .../plugin-security/src/security-plugin.ts | 7 + 8 files changed, 485 insertions(+) create mode 100644 .changeset/owd-posture-authoring-gate.md create mode 100644 packages/plugins/plugin-security/src/object-posture-gate.test.ts create mode 100644 packages/plugins/plugin-security/src/object-posture-gate.ts diff --git a/.changeset/owd-posture-authoring-gate.md b/.changeset/owd-posture-authoring-gate.md new file mode 100644 index 0000000000..cfeb7d2279 --- /dev/null +++ b/.changeset/owd-posture-authoring-gate.md @@ -0,0 +1,6 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/plugin-security': minor +--- + +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. diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index f449252b53..b37d674040 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -3,6 +3,7 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js'; +export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; export type { diff --git a/packages/metadata-protocol/src/mutation-listeners.test.ts b/packages/metadata-protocol/src/mutation-listeners.test.ts index fdcdec9cd6..a88fadae14 100644 --- a/packages/metadata-protocol/src/mutation-listeners.test.ts +++ b/packages/metadata-protocol/src/mutation-listeners.test.ts @@ -116,3 +116,73 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094) expect(out).toEqual({ success: false, error: 'projection boom' }); }); }); + +// #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. +describe('ObjectStackProtocolImplementation.registerAuthoringGate (#3050)', () => { + const save = (over: Record = {}) => ({ + type: 'object', name: 'crm_account', state: 'active' as const, body: { sharingModel: 'private' }, ...over, + }); + + it('dispatches the registered gate with the body and state', async () => { + const p = makeProtocol(); + const seen: any[] = []; + p.registerAuthoringGate('object', (ctx) => { seen.push(ctx); }); + await (p as any).runAuthoringGate(save({ state: 'draft' })); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + type: 'object', name: 'crm_account', state: 'draft', + body: { sharingModel: 'private' }, isArtifactBacked: false, + }); + }); + + it('normalizes plural registrations to the singular type', async () => { + const p = makeProtocol(); + const gate = vi.fn(); + p.registerAuthoringGate('objects', gate); + await (p as any).runAuthoringGate(save()); + expect(gate).toHaveBeenCalledTimes(1); + }); + + it('a gate throw PROPAGATES with its status/code (the write is rejected)', async () => { + const p = makeProtocol(); + p.registerAuthoringGate('object', () => { + const err: any = new Error('[owd_widening_forbidden] no widening'); + err.code = 'owd_widening_forbidden'; err.status = 403; + throw err; + }); + await expect((p as any).runAuthoringGate(save())).rejects.toMatchObject({ + code: 'owd_widening_forbidden', status: 403, + }); + }); + + it('is a no-op for types with no registered gate', async () => { + const p = makeProtocol(); + await expect((p as any).runAuthoringGate(save({ type: 'view' }))).resolves.toBeUndefined(); + }); + + it('resolves isArtifactBacked + declaredBody from the artifact registry', async () => { + const declared = { name: 'crm_account', sharingModel: 'private', _packageId: 'crm' }; + const engine = { registry: { getItem: (_t: string, n: string) => (n === 'crm_account' ? declared : undefined) } }; + const p = new ObjectStackProtocolImplementation(engine as any); + const seen: any[] = []; + p.registerAuthoringGate('object', (ctx) => { seen.push(ctx); }); + await (p as any).runAuthoringGate(save()); + expect(seen[0].isArtifactBacked).toBe(true); + expect(seen[0].declaredBody).toBe(declared); + }); + + it('a second registration replaces the first (idempotent re-init)', async () => { + const p = makeProtocol(); + const first = vi.fn(); const second = vi.fn(); + p.registerAuthoringGate('object', first); + p.registerAuthoringGate('object', second); + await (p as any).runAuthoringGate(save()); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7614ef07c7..ff8def2fb0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -799,6 +799,40 @@ export interface MutationProjectionOutcome { error?: string; } +/** + * Pre-persistence authoring gate (ADR-0094 addendum seam; #3050). + * + * Unlike the post-persist {@link MetadataMutationProjector} (best-effort, + * never thrown), an authoring gate runs BEFORE persistence and REJECTS the + * write by throwing — it is the seam for domain invariants that must hold on + * every runtime-authored body regardless of which HTTP surface produced it + * (e.g. plugin-security's OWD posture gate: an environment may only TIGHTEN + * a packaged object's `sharingModel`, and `externalSharingModel ≤ + * sharingModel` per ADR-0090 D11). + * + * Invoked inside `saveMetaItem` for BOTH draft and publish-mode saves, after + * the ADR-0005 overlay/runtime-create authorization and the per-type spec + * validation — so `publishMetaItem` promotes an already-gated body and needs + * no second gate. Environment writes only: control-plane bootstrap writes + * (`environmentId === undefined`) are the package author's own channel and + * bypass the gate, mirroring the ADR-0005 gate above. + */ +export interface MetadataAuthoringGateContext { + /** Singular type name (e.g. `object`). */ + type: string; + name: string; + /** Lifecycle the body is being saved into. */ + state: 'draft' | 'active'; + organizationId?: string; + /** The body being persisted. */ + body: unknown; + /** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */ + isArtifactBacked: boolean; + /** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */ + declaredBody?: unknown; +} +export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise; + export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; @@ -847,6 +881,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { */ private mutationProjectors = new Map(); + /** + * Pre-persistence authoring gates (#3050). One per type; a second + * registration replaces the first (idempotent re-init). Unlike + * projectors these THROW to reject the write — see + * {@link MetadataAuthoringGate}. + */ + private authoringGates = new Map(); + constructor( engine: IDataEngine, getServicesRegistry?: () => Map, @@ -894,6 +936,49 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { this.mutationProjectors.set(singular, projector); } + /** + * Register the pre-persistence authoring gate for a metadata type + * (ADR-0094 addendum seam; #3050). Called by domain plugins at init — + * e.g. plugin-security registers the `object` OWD posture gate. The gate + * THROWS to reject the write. Singular or plural type names both resolve; + * one gate per type, a second registration replaces the first. + */ + registerAuthoringGate(type: string, gate: MetadataAuthoringGate): void { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + this.authoringGates.set(singular, gate); + } + + /** + * Run the registered authoring gate for an about-to-persist body (#3050). + * No-op when no gate is registered for the type. A gate throw PROPAGATES + * (with its status/code) — that is the contract: the write is rejected + * before persistence. Resolves the artifact-backed flag and the packaged + * declaration body (the baseline an overlay customizes) for the gate. + */ + private async runAuthoringGate(evt: { + type: string; name: string; state: 'draft' | 'active'; organizationId?: string; body: unknown; + }): Promise { + const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type; + const gate = this.authoringGates.get(singular); + if (!gate) return; + const artifactBacked = this.isArtifactBacked(evt.type, evt.name); + let declaredBody: unknown; + if (artifactBacked && typeof this.engine.registry?.getItem === 'function') { + const alt = PLURAL_TO_SINGULAR[evt.type] ?? SINGULAR_TO_PLURAL[evt.type]; + declaredBody = this.engine.registry.getItem(evt.type, evt.name) + ?? (alt ? this.engine.registry.getItem(alt, evt.name) : undefined); + } + await gate({ + type: singular, + name: evt.name, + state: evt.state, + ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), + body: evt.body, + isArtifactBacked: artifactBacked, + ...(declaredBody !== undefined ? { declaredBody } : {}), + }); + } + /** * Run the registered projector for a just-persisted mutation (ADR-0094). * Returns `undefined` when no projector is registered for the type; @@ -4010,6 +4095,22 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // 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) { + await this.runAuthoringGate({ + type: request.type, + name: request.name, + state: mode === 'draft' ? 'draft' : 'active', + ...(request.organizationId ? { organizationId: request.organizationId } : {}), + body: request.item, + }); + } + // 1. Update the in-memory registry (runtime cache) ONLY for the // `object` type — schema definitions feed engine.syncSchema and // must be reflected immediately for CRUD to work. For all other diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 3442a3814b..d652c1a1b9 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -44,6 +44,8 @@ export type { } from './permission-set-projection.js'; export { cleanupPackagePermissions } from './cleanup-package-permissions.js'; export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js'; +export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; +export type { ObjectPostureGateContext } from './object-posture-gate.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; diff --git a/packages/plugins/plugin-security/src/object-posture-gate.test.ts b/packages/plugins/plugin-security/src/object-posture-gate.test.ts new file mode 100644 index 0000000000..ddfaf779af --- /dev/null +++ b/packages/plugins/plugin-security/src/object-posture-gate.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #3050 — OWD posture authoring gate: env-tighten-only over packaged +// declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11), enforced +// on the runtime write path (previously CLI-lint-only). + +import { describe, it, expect } from 'vitest'; +import { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; + +const base = (over: Partial[0]> = {}) => ({ + type: 'object', + name: 'crm_account', + body: {}, + isArtifactBacked: false, + ...over, +}); + +describe('R2 — external ≤ internal (ADR-0090 D11)', () => { + it('rejects external wider than internal', () => { + expect(() => objectPostureGate(base({ + body: { sharingModel: 'public_read', externalSharingModel: 'public_read_write' }, + }))).toThrowError(/owd_external_wider/); + }); + + it('rejects explicit external on an OWD-less body (internal defaults to private, ADR-0090 D1)', () => { + expect(() => objectPostureGate(base({ + body: { externalSharingModel: 'public_read' }, + }))).toThrowError(/owd_external_wider/); + }); + + it('accepts external equal to internal', () => { + expect(() => objectPostureGate(base({ + body: { sharingModel: 'public_read', externalSharingModel: 'public_read' }, + }))).not.toThrow(); + }); + + it('accepts external tighter than internal', () => { + expect(() => objectPostureGate(base({ + body: { sharingModel: 'public_read_write', externalSharingModel: 'private' }, + }))).not.toThrow(); + }); + + it('skips ordering when either side is controlled_by_parent (inherits master pair)', () => { + expect(() => objectPostureGate(base({ + body: { sharingModel: 'controlled_by_parent', externalSharingModel: 'public_read' }, + }))).not.toThrow(); + expect(() => objectPostureGate(base({ + body: { sharingModel: 'public_read', externalSharingModel: 'controlled_by_parent' }, + }))).not.toThrow(); + }); + + it('accepts a body with no posture fields at all', () => { + expect(() => objectPostureGate(base({ body: { name: 'crm_account', fields: {} } }))).not.toThrow(); + }); + + it('carries 403 + code on the error', () => { + try { + objectPostureGate(base({ body: { sharingModel: 'private', externalSharingModel: 'public_read' } })); + expect.unreachable('should have thrown'); + } catch (e: any) { + expect(e.status).toBe(403); + expect(e.code).toBe('owd_external_wider'); + } + }); +}); + +describe('R1 — env-tighten-only over a packaged declaration (ADR-0086 D1)', () => { + it('rejects widening internal beyond the declared baseline', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'private' }, + body: { sharingModel: 'public_read_write' }, + }))).toThrowError(/owd_widening_forbidden/); + }); + + it('rejects widening when the declaration is OWD-less (baseline = private per ADR-0090 D1)', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { name: 'crm_account' }, + body: { sharingModel: 'public_read' }, + }))).toThrowError(/owd_widening_forbidden/); + }); + + it('rejects widening external beyond the declared external (default private, D11)', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'public_read' }, + body: { sharingModel: 'public_read', externalSharingModel: 'public_read' }, + }))).toThrowError(/owd_widening_forbidden/); + }); + + it('accepts tightening the packaged posture', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'public_read_write', externalSharingModel: 'public_read' }, + body: { sharingModel: 'private', externalSharingModel: 'private' }, + }))).not.toThrow(); + }); + + it('accepts an overlay that leaves posture unchanged', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'public_read' }, + body: { sharingModel: 'public_read', label: 'Renamed' }, + }))).not.toThrow(); + }); + + it('accepts an overlay that omits posture fields entirely', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'private' }, + body: { label: 'Renamed' }, + }))).not.toThrow(); + }); + + it('skips tighten comparison when declared side is controlled_by_parent', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + declaredBody: { sharingModel: 'controlled_by_parent' }, + body: { sharingModel: 'public_read' }, + }))).not.toThrow(); + }); + + it('does not apply R1 to runtime-created (non-artifact) objects — env owns them', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: false, + body: { sharingModel: 'public_read_write' }, + }))).not.toThrow(); + }); + + it('does not apply R1 when the declared baseline is unavailable', () => { + expect(() => objectPostureGate(base({ + isArtifactBacked: true, + body: { sharingModel: 'public_read_write' }, + }))).not.toThrow(); + }); +}); + +describe('registerObjectPostureGate wiring', () => { + it('registers on a protocol exposing registerAuthoringGate and rejects through it', async () => { + const gates = new Map void | Promise>(); + const protocol = { + registerAuthoringGate: (type: string, gate: (ctx: any) => void) => gates.set(type, gate), + }; + expect(registerObjectPostureGate(protocol)).toBe(true); + const gate = gates.get('object')!; + expect(gate).toBeTypeOf('function'); + await expect(async () => gate({ + type: 'object', name: 'crm_account', body: { sharingModel: 'private', externalSharingModel: 'public_read' }, + isArtifactBacked: false, + })).rejects.toThrowError(/owd_external_wider/); + }); + + it('feature-detects: returns false on a protocol without the seam', () => { + expect(registerObjectPostureGate({})).toBe(false); + expect(registerObjectPostureGate(undefined)).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/object-posture-gate.ts b/packages/plugins/plugin-security/src/object-posture-gate.ts new file mode 100644 index 0000000000..4ef5fb09dd --- /dev/null +++ b/packages/plugins/plugin-security/src/object-posture-gate.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * OWD posture authoring gate for the `object` metadata type (#3050). + * + * Registered on the metadata protocol's pre-persistence authoring-gate seam + * (ADR-0094 addendum; `registerAuthoringGate`), so it fires on EVERY + * runtime-authored object body — Studio drafts, direct REST saves, AI + * builders — regardless of which HTTP surface produced the write. It closes + * the two posture rules that were previously CLI-lint-only + * (`packages/lint/src/validate-security-posture.ts` runs at `os compile` / + * `os lint`, never on `saveMetaItem`): + * + * - **R1 — env-tighten-only (ADR-0086 D1, ADR-0049).** An environment write + * over a PACKAGED object (artifact-backed — reachable only via the + * `OS_METADATA_WRITABLE=object` escape hatch, since `object` ships + * `allowOrgOverride:false`) may not set `sharingModel` / + * `externalSharingModel` WIDER than the packaged declaration. Widening + * legitimately = author it in the package source and publish (ADR-0090 + * D7), never an env overlay. + * - **R2 — external ≤ internal (ADR-0090 D11).** Any object write must keep + * `externalSharingModel` no wider than `sharingModel`. Previously stated + * only in `.describe()` prose (`object.zod.ts`) and the lint rule + * `SECURITY_EXTERNAL_WIDER`. + * + * Deliberately write-path only — no zod refine, so grandfathered stored + * metadata keeps loading (the ADR-0090 D1 lesson: never change behavior of + * data at rest, gate the new writes). `controlled_by_parent` is excluded + * from ordering on either side (it inherits the master's pair), mirroring + * the lint's `OWD_WIDTH` semantics. + */ + +/** + * D11 openness ordering — mirrors `OWD_WIDTH` in + * `packages/lint/src/validate-security-posture.ts` (`controlled_by_parent` + * deliberately absent: it inherits the detail-master's pair and cannot be + * ordered locally). + */ +const OWD_WIDTH: Record = { + private: 0, + public_read: 1, + public_read_write: 2, +}; + +function widthOf(value: unknown): number | undefined { + return typeof value === 'string' ? OWD_WIDTH[value] : undefined; +} + +function postureError(code: string, message: string): Error { + const err = new Error(`[${code}] ${message}`); + (err as any).code = code; + (err as any).status = 403; + return err; +} + +/** Context subset of the protocol's MetadataAuthoringGateContext this gate consumes. */ +export interface ObjectPostureGateContext { + type: string; + name: string; + body: unknown; + isArtifactBacked: boolean; + declaredBody?: unknown; +} + +/** + * The gate. Throws 403 `owd_external_wider` / `owd_widening_forbidden` to + * reject the write; returns silently when the body passes. + */ +export function objectPostureGate(ctx: ObjectPostureGateContext): void { + const body = ctx.body as Record | null; + if (!body || typeof body !== 'object') return; + + const internal = body['sharingModel']; + const external = body['externalSharingModel']; + const wInternal = widthOf(internal); + const wExternal = widthOf(external); + + // R2 — ADR-0090 D11: external ≤ internal. Both sides must be orderable + // canonical scalars; `controlled_by_parent` (or an unset side) is skipped, + // mirroring the lint's SECURITY_EXTERNAL_WIDER rule. An unset internal on + // a custom object resolves to `private` at runtime (ADR-0090 D1), so an + // explicit external wider than that is also caught. + const wInternalEffective = wInternal ?? (internal == null ? OWD_WIDTH['private'] : undefined); + if (wExternal !== undefined && wInternalEffective !== undefined && wExternal > wInternalEffective) { + throw postureError( + 'owd_external_wider', + `object/${ctx.name}: externalSharingModel '${String(external)}' is wider than sharingModel ` + + `'${String(internal ?? 'private (default)')}' — external must be ≤ internal (ADR-0090 D11). ` + + `Tighten externalSharingModel or widen sharingModel in the object definition.`, + ); + } + + // R1 — ADR-0086 D1: an environment may only TIGHTEN a packaged object's + // posture. Applies only to overlay writes over an artifact-backed object + // (the OS_METADATA_WRITABLE escape-hatch path — the default deploy already + // 403s these before this gate runs). + if (!ctx.isArtifactBacked) return; + const declared = ctx.declaredBody as Record | null; + if (!declared || typeof declared !== 'object') return; + + // Baseline widths. An undeclared internal baselines to `private` (the + // ADR-0090 D1 default for custom objects — fail-closed: an env overlay of + // an OWD-less packaged object may not introduce a wider posture); an + // undeclared external baselines to `private` (the D11 default). + // `controlled_by_parent` on either side of a comparison skips that + // comparison (not orderable locally). + const declaredInternal = declared['sharingModel']; + const declaredExternal = declared['externalSharingModel']; + const wDeclaredInternal = widthOf(declaredInternal) ?? (declaredInternal == null ? OWD_WIDTH['private'] : undefined); + const wDeclaredExternal = widthOf(declaredExternal) ?? (declaredExternal == null ? OWD_WIDTH['private'] : undefined); + + const violations: string[] = []; + if (wInternal !== undefined && wDeclaredInternal !== undefined && wInternal > wDeclaredInternal) { + violations.push(`sharingModel '${String(internal)}' > declared '${String(declaredInternal ?? 'private (default)')}'`); + } + if (wExternal !== undefined && wDeclaredExternal !== undefined && wExternal > wDeclaredExternal) { + violations.push(`externalSharingModel '${String(external)}' > declared '${String(declaredExternal ?? 'private (default)')}'`); + } + if (violations.length > 0) { + throw postureError( + 'owd_widening_forbidden', + `object/${ctx.name}: an environment overlay may only TIGHTEN a packaged object's OWD, never widen it ` + + `(${violations.join('; ')}). Widen it in the package source and publish through the package pipeline ` + + `(ADR-0090 D7) instead of an environment overlay (ADR-0086 D1).`, + ); + } +} + +/** + * Wire the gate onto the protocol. Feature-detected like + * `registerPermissionSetProjection` — a protocol that predates + * `registerAuthoringGate` (older embeddings, unit-test stubs) simply keeps + * the legacy behavior (CLI lint remains the only guard there). Returns + * `true` when wired. + */ +export function registerObjectPostureGate(protocol: any): boolean { + if (!protocol || typeof protocol.registerAuthoringGate !== 'function') return false; + protocol.registerAuthoringGate('object', (ctx: ObjectPostureGateContext) => objectPostureGate(ctx)); + return true; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d886a394e1..3a17169efb 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -20,6 +20,7 @@ import { registerPermissionSetProjection, reconcilePermissionSetProjection, } from './permission-set-projection.js'; +import { registerObjectPostureGate } from './object-posture-gate.js'; import { syncAudienceBindingSuggestions, listAudienceBindingSuggestions, @@ -1571,6 +1572,12 @@ export class SecurityPlugin implements Plugin { ql, metadata: this.metadata, logger: ctx.logger, }); } + // [#3050] OWD posture authoring gate — pre-persistence veto on + // runtime-authored `object` bodies: env-tighten-only over packaged + // declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11). + // Feature-detected; protocols predating registerAuthoringGate keep + // the legacy (CLI-lint-only) behavior. + registerObjectPostureGate(protocol); // [ADR-0094 D4] Converge record ↔ metadata: project env overlays // onto records (creating missing ones), backfill legacy data-door // creations into the metadata store once, and heal drifted records From 0d28f0c76b6c86972afca97781f478d1b8d142e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:47:16 +0000 Subject: [PATCH 2/2] docs(adr): flip 10 verified-implemented Proposed ADRs to Accepted; annotate 0057/0025 Per-decision code+test verification (2026-07-16 audit, same method as the ADR-0086 re-evaluation): 0047, 0049, 0053, 0056, 0058, 0062, 0066, 0068, 0081 flip to Accepted with one-line landing evidence. 0057 (ERP authz) becomes Accepted-in-part: D1-D3/D8 landed, D4-D7 sys_role/role-hierarchy decisions superseded by ADR-0090 D3/ADR-0095 positions. 0025 stays Proposed with an honest partial-implementation note (install flow + sys_plugin_* registry missing). 0056's status notes its addendum's stale D6 RoleGraphService claim. --- docs/adr/0025-plugin-package-distribution.md | 2 +- docs/adr/0047-object-ui-run-modes.md | 2 +- docs/adr/0049-no-unenforced-security-properties.md | 2 +- docs/adr/0053-date-and-datetime-semantics.md | 2 +- docs/adr/0056-permission-model-landing-verification.md | 2 +- ...057-erp-authorization-core-business-units-and-scope-depth.md | 2 +- docs/adr/0058-expression-and-predicate-surface.md | 2 +- docs/adr/0062-external-datasource-runtime.md | 2 +- docs/adr/0066-unified-authorization-model.md | 2 +- .../0068-unified-user-context-and-built-in-identity-roles.md | 2 +- docs/adr/0081-trusted-react-page-tier.md | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md index 25106d8942..f154e047aa 100644 --- a/docs/adr/0025-plugin-package-distribution.md +++ b/docs/adr/0025-plugin-package-distribution.md @@ -1,6 +1,6 @@ # ADR-0025: Plugin Package Distribution (Code + Dependencies) -**Status**: Proposed +**Status**: Proposed — partially implemented (2026-07-16 audit): the `.osplugin` artifact format and `os plugin build`/`sign`/`publish` CLI landed; the install flow (§3.5), `sys_plugin`/`sys_plugin_version`/`sys_plugin_installation` registry (§3.8), and install-time consent remain unimplemented. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package + versioned releases), [ADR-0004](./0004-cloud-multi-kernel.md) (cloud multi-kernel), [ADR-0010](./0010-metadata-protection-model.md) (L1/L2/L3 protection), [ADR-0016](./0016-studio-package-authoring-and-publish.md) (package authoring & publish, local export/import) **Consumers**: `@objectstack/core` (kernel, plugin-loader, security), `@objectstack/runtime` (sandbox, marketplace install), `@objectstack/cli`, `@objectstack/spec/system` (ObjectStackManifest), `@objectstack/spec/cloud`, `../objectui` (Studio) diff --git a/docs/adr/0047-object-ui-run-modes.md b/docs/adr/0047-object-ui-run-modes.md index 5af79c685f..b50b52863d 100644 --- a/docs/adr/0047-object-ui-run-modes.md +++ b/docs/adr/0047-object-ui-run-modes.md @@ -1,6 +1,6 @@ # ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice -**Status**: Proposed (2026-06-12) · **Revised 2026-06-19** +**Status**: Accepted (2026-06-12; revised 2026-06-19) — framework scope implemented (`UserFiltersSchema`/`InterfacePageConfigSchema` + the 2026-07-07 object-list narrowing, reference-integrity diagnostics); the PageView render + Studio panel phases live in objectui. > **Revision (2026-06-19) — the "iron rule" is superseded.** Validating the > Studio config panel against Airtable showed Airtable has no "inherit from a diff --git a/docs/adr/0049-no-unenforced-security-properties.md b/docs/adr/0049-no-unenforced-security-properties.md index 80e9159f9b..1f274f834e 100644 --- a/docs/adr/0049-no-unenforced-security-properties.md +++ b/docs/adr/0049-no-unenforced-security-properties.md @@ -1,6 +1,6 @@ # ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) -**Status**: Proposed (2026-06-15) +**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`permission-evaluator.ts:37,122`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) **Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index d5903034ec..3f0b041add 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -1,6 +1,6 @@ # ADR-0053: `date` is a timezone-naive calendar day; `datetime` is an instant rendered in a reference timezone -**Status**: Proposed (2026-06-16) +**Status**: Accepted (2026-06-16) — Phase 1 + addendum D-A1 implemented (`sql-driver.ts` `toDateOnly` write/read/filter normalization; analytics `coerceTemporalFilterValue`), Phase 2 landing incrementally; D-A2 (`temporalFilterValue` promotion onto the `IDataDriver` contract) still open as the ADR predicted. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0032](./0032-unified-expression-layer.md) (unified expression layer — CEL dialect, `today()`/`daysFromNow()`), [ADR-0014](./0014-record-form-field-type.md) (field types) **Consumers**: `@objectstack/spec` (`Field.date`/`Field.datetime`), `@objectstack/driver-sql` (`coerceFilterValue`, `formatInput`/`formatOutput`, `dateFields`/`datetimeFields`), `@objectstack/formula` (`stdlib` time functions, `cel-engine` hydration), `@objectstack/objectql` (`applyFormulaPlan`), schedule/cron executors, report/analytics date bucketing, `sys-user-preference.timezone`. diff --git a/docs/adr/0056-permission-model-landing-verification.md b/docs/adr/0056-permission-model-landing-verification.md index 6b6a3235b4..a7f3083f1b 100644 --- a/docs/adr/0056-permission-model-landing-verification.md +++ b/docs/adr/0056-permission-model-landing-verification.md @@ -1,6 +1,6 @@ # ADR-0056: Permission Model Landing Verification — Whole-Model Enforce / Prove / Reconcile Audit -**Status**: Proposed (2026-06-20) +**Status**: Accepted (2026-06-20) — landed per the implementation-status addendum (D2 anonymous default-deny, D4 fail-closed RLS compile, D7 app default set, D10 conformance matrix CI-gated). Caveat: the addendum's D6 row is stale — `RoleGraphService` never shipped; role-hierarchy widening was superseded by the ADR-0090 position/business-unit model (positions are flat, rollup lives on the BU tree). **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), [ADR-0055](./0055-master-detail-controlled-by-parent.md) (controlled-by-parent) **Consumers**: `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/plugin-org-scoping`, `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/spec`, `@objectstack/verify` diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index f6744cb4b7..a18cc67292 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -1,6 +1,6 @@ # ADR-0057: ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierarchy Rollup -**Status**: Proposed (2026-06-21) +**Status**: Accepted in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum + evaluator, `sys_business_unit` tree, hierarchy-resolver seam, conformance rows); **D4–D7 superseded by ADR-0090 D3 / ADR-0095**: the `sys_role`/`sys_user_role`/`role_and_subordinates` vocabulary never shipped — the code realizes them as flat `sys_position`/`sys_user_position` with rollup on the BU tree. This ADR's own implementation-status table predates that supersession; read it with the rename applied. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0010](./0010-metadata-protection.md) (metadata protection / object ownership), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), diff --git a/docs/adr/0058-expression-and-predicate-surface.md b/docs/adr/0058-expression-and-predicate-surface.md index a51864e289..379da95b9f 100644 --- a/docs/adr/0058-expression-and-predicate-surface.md +++ b/docs/adr/0058-expression-and-predicate-surface.md @@ -1,6 +1,6 @@ # ADR-0058: The Expression & Predicate Surface — One Authoring Language, Two Backends, and the Pushdown-Compiler Reconciliation (#1887) -**Status**: Proposed (2026-06-21) +**Status**: Accepted (2026-06-21) — implemented: canonical CEL→Filter compiler (`formula/src/cel-to-filter.ts`), RLS + sharing cutover (`rls-compiler.ts`, `bootstrap-declared-sharing-rules.ts`), `check` enforced on writes, expression-surface conformance ledger CI-gated (`dogfood/test/expression-conformance.ledger.ts`). **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (runtime proof), [ADR-0055](./0055-master-detail-controlled-by-parent.md) (RLS reuses pre-resolved membership IN-form; **no compiler subquery**), [ADR-0056](./0056-permission-model-landing-verification.md) (permission-model landing), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (ERP authz core) **Consumers**: `@objectstack/formula`, `@objectstack/objectql`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/service-analytics`, `@objectstack/service-automation`, `@objectstack/spec`, `@objectstack/verify` diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md index f508e98412..114a9489c7 100644 --- a/docs/adr/0062-external-datasource-runtime.md +++ b/docs/adr/0062-external-datasource-runtime.md @@ -1,6 +1,6 @@ # ADR-0062: External Datasource Runtime — connection lifecycle, credentials, visibility & query completeness -**Status**: Proposed — recommended for acceptance; consolidates the runtime gaps surfaced while implementing ADR-0015 federation. Some requirements are already shipped (marked ✅ below); the open decisions are D1–D8 (2026-06-22). +**Status**: Accepted (2026-06-22) — D1–D8 implemented (`service-datasource` connection service + opt-in-safe gate + fail-closed connect policy; native-SQL declines external per D6; D7 lint in `validate-expressions.ts`). **Supersedes the runtime portions of**: ADR-0015 §18 addendum (kept as the historical record). ADR-0015 remains the canonical spec/binding decision; this ADR is the canonical *runtime* decision. diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 5ec8ed7308..6e26275127 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -1,6 +1,6 @@ # ADR-0066: Unified authorization model — capability registry, secure-by-default posture, resource→capability contracts, dual-surface gates -**Status**: Proposed (2026-06-23) +**Status**: Accepted (2026-06-23) — D1–D5 + refinements ⑤/⑨ implemented (capability registry + `defineCapability` seeding, `access.default` posture with posture-gated superuser bypass, `requiredPermissions` AND-gates on object/field/action, `crudBucketForOperation`, `validateCapabilityReferences` lint); D4's UI half enforced in objectui ActionRunner. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`readScope`/`writeScope` depth: own/unit/unit_and_below/org), [ADR-0058](./0058-expression-and-predicate-surface.md) (CEL predicate surface used by RLS); relates to cloud ADR-0016 (authz open/paid boundary) **Consumers**: `@objectstack/spec` (object/app/permission schemas), `@objectstack/plugin-security` (RLS/FLS compiler + enforcement), `../objectui` (ActionRunner + app/nav gating), `../cloud` (control-plane objects, e.g. `sys_license`) diff --git a/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md b/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md index 5b0727a6c1..333381fc2d 100644 --- a/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md +++ b/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md @@ -1,6 +1,6 @@ # ADR-0068: Unified user-context contract & built-in identity roles — one `current_user` shape across formula/RLS/client, identities as roles not booleans -**Status**: Proposed (2026-06-24) +**Status**: Accepted (2026-06-24) — implemented under the position model: `EvalUser` factory + `current_user` mount (`spec/identity/eval-user.zod.ts`, `formula/stdlib.ts`), built-in identity rows seeded (`bootstrap-builtin-positions.ts`), derived `isPlatformAdmin`. Note: this ADR's `roles[]` is realized as `current_user.positions[]` per ADR-0090 D3 / ADR-0095. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; `ExecutionContext.roles` = union of `sys_member.role` + `sys_user_role.role`), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment/requirement separation; resources declare a capability, never "who"), [ADR-0058](./0058-expression-and-predicate-surface.md) (CEL predicate surface) **Consumers**: `@objectstack/spec` (the `EvalUser` contract), `@objectstack/plugin-security` (`resolve-execution-context`, RLS), `@objectstack/plugin-auth` (`customSession` bridge), `@objectstack/formula` (`buildScope`), `@objectstack/runtime`, `../objectui` (AuthProvider / ExpressionProvider / predicate scope), `../cloud` (control-plane metadata — consumer only) diff --git a/docs/adr/0081-trusted-react-page-tier.md b/docs/adr/0081-trusted-react-page-tier.md index f819901982..a7556cb9ae 100644 --- a/docs/adr/0081-trusted-react-page-tier.md +++ b/docs/adr/0081-trusted-react-page-tier.md @@ -1,6 +1,6 @@ # ADR-0081: A trusted `kind:'react'` page tier — real React executed in the main tree, gated by a host capability; and renaming `kind:'jsx'` → `kind:'html'` -**Status**: Proposed (2026-06-30) +**Status**: Accepted (2026-06-30) — framework scope implemented (`html`/`react` page kinds + `jsx` alias, lint split `validate-jsx-pages`/`validate-react-pages`, `OS_PAGE_REACT=off` server toggle); the `@object-ui/react-runtime` executor + `CAP_REACT_PAGES` gate live in objectui. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0080](./0080-ai-authored-ui-jsx-source.md) (AI authors a *constrained* JSX text, parsed-never-executed, into the SDUI tree; the component registry is the contract; capability ≠ contract), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI writes metadata via draft-gated `saveMetaItem` — the human-review boundary), [ADR-0077](./0077-authoring-surface-boundary-hook-flow-validation.md) (route by intent; verifiability tier = friction tier; **code is the flagged escape hatch**), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata). **Consumers**: `@objectstack/spec` (`packages/spec/src/ui/page.zod.ts` — `kind` gains `'html'` and `'react'`; `'jsx'` kept as a deprecated alias), `@objectstack/lint` (`validate-jsx-pages` — lints `html`/`jsx`, **skips** `react`), `../objectui` `@object-ui/react-runtime` (**new** — the trusted runtime-React executor) + `@object-ui/core` (the host capability gate) + `@object-ui/components` (PageRenderer routing + the full HTML tag set).