From 98dd22202d32564b843f223058031d6ceed722b6 Mon Sep 17 00:00:00 2001 From: os-sam Date: Wed, 9 Sep 2026 12:02:07 +0000 Subject: [PATCH 1/4] feat(runtime): bind the artifact's install-time granted permissions per plugin at load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `EnvironmentArtifactSchema.grantedPermissions` into `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time — the consumer half the artifact contract names, and the key-to-plugin binding that did not exist before: one `AppPlugin` covers a whole artifact, so nothing in the load path could say which package a grant entry belonged to. Absent, `{}` and a consented entry stay three distinct states, both directions. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude --- .../app-plugin.granted-permissions.test.ts | 98 +++++++++ packages/runtime/src/app-plugin.ts | 93 +++++++- packages/runtime/src/index.ts | 4 + .../artifact-granted-permissions.test.ts | 188 ++++++++++++++++ .../security/artifact-granted-permissions.ts | 202 ++++++++++++++++++ packages/runtime/src/security/index.ts | 12 ++ 6 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/app-plugin.granted-permissions.test.ts create mode 100644 packages/runtime/src/security/artifact-granted-permissions.test.ts create mode 100644 packages/runtime/src/security/artifact-granted-permissions.ts diff --git a/packages/runtime/src/app-plugin.granted-permissions.test.ts b/packages/runtime/src/app-plugin.granted-permissions.test.ts new file mode 100644 index 0000000000..eee20c008b --- /dev/null +++ b/packages/runtime/src/app-plugin.granted-permissions.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13457 — `AppPlugin.init()` is the ONE production caller of the artifact→ +// enforcer seam, and this file pins that it is: the wiring, not the seam's own +// logic (that is `security/artifact-granted-permissions.test.ts`). +// +// The site matters as much as the behaviour. `AppPlugin` is the single point +// where an environment artifact becomes a kernel plugin on BOTH paths — the +// self-hosted `createStandaloneStack` and the cloud control plane's +// `ArtifactKernelFactory`, which constructs the same object — so a consent +// record reaches the enforcer without either caller changing a line. Before +// this, `PluginPermissionEnforcer` had zero production callers (#7500, +// re-measured on this branch). + +import { describe, it, expect, vi } from 'vitest'; +import { AppPlugin } from './app-plugin.js'; +import type { PluginContext } from '@objectstack/core'; + +const bootCtx = () => { + const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + return { + logger, + registerService: vi.fn(), + registerServiceFactory: vi.fn(), + replaceService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + getServiceScoped: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + } as unknown as PluginContext & { logger: typeof logger }; +}; + +const bundle = (extra: Record = {}) => ({ + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }, + packages: [ + { manifest: { id: 'com.acme.crm', name: 'crm', version: '1.0.0', type: 'app' } }, + { manifest: { id: 'com.acme.reports', name: 'reports', version: '1.0.0', type: 'module' } }, + ], + ...extra, +}); + +describe('#13457 — AppPlugin.init binds the artifact\'s granted permissions', () => { + it('an artifact with NO grantedPermissions key allocates no enforcer at all', async () => { + const ctx = bootCtx(); + const plugin = new AppPlugin(bundle()); + await plugin.init(ctx); + + // ⭐ Not "an enforcer that denies nothing" — no enforcer. This is every + // artifact that ships today, and the boot has to be byte-for-byte what + // it was: absent is no consent record, never a deny. + expect(plugin.permissionEnforcer).toBeUndefined(); + expect(plugin.grantBinding).toBeUndefined(); + }); + + it('a consent-bearing artifact registers each entry under its manifest id', async () => { + const ctx = bootCtx(); + const plugin = new AppPlugin(bundle({ + grantedPermissions: { 'com.acme.crm': { services: ['object'], hooks: [] } }, + })); + await plugin.init(ctx); + + const e = plugin.permissionEnforcer; + expect(e).toBeDefined(); + // Keyed by the plugin manifest `id` — ⛔ never the kernel plugin name + // (`plugin.app.com.acme.crm`), which is what `AppPlugin` registers + // ITSELF under and is not the key the artifact contract writes. + expect(plugin.name).toBe('plugin.app.com.acme.crm'); + expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('object')).toBe(true); + expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('storage')).toBe(false); + // The sibling package carries no consent record — ungated, unregistered. + expect(e!.getPluginPermissions('com.acme.reports')).toBeUndefined(); + expect(plugin.grantBinding).toMatchObject({ + declared: true, + gated: ['com.acme.crm'], + ungated: ['com.acme.reports'], + unbound: [], + }); + }); + + it('binds on an EMPTY environment too, so a grant that binds to nothing is still heard', async () => { + // An empty env has no app payload and `init()` returns early — but the + // binding runs BEFORE that return, because an artifact carrying grants + // for packages it does not ship is exactly the fault that shows up here. + const ctx = bootCtx(); + const plugin = new AppPlugin({ + manifest: { plugins: [], drivers: [] }, + grantedPermissions: { 'com.acme.ghost': { services: ['object'] } }, + }); + await plugin.init(ctx); + + expect(plugin.grantBinding).toMatchObject({ declared: true, gated: [], unbound: ['com.acme.ghost'] }); + expect( + ctx.logger.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), + ).toBe(true); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 71123c8a6e..37b9c37559 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1,7 +1,17 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; +import { + Plugin, + PluginContext, + createPluginPermissionEnforcer, + wireAuthoredTranslationSync, + type PluginPermissionEnforcer, +} from '@objectstack/core'; import { resolveArtifactCollections } from './artifact-collections.js'; +import { + registerArtifactGrantedPermissions, + type ArtifactGrantBinding, +} from './security/artifact-granted-permissions.js'; import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core'; import { resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; @@ -125,6 +135,22 @@ export class AppPlugin implements Plugin { * of arriving at teardown time. */ private initCtx?: PluginContext; + /** + * The enforcer holding this artifact's install-time GRANTED permission sets + * (ADR-0025 F4), or `undefined` when the artifact carried no + * `grantedPermissions` key — which is every artifact built before consent + * existed and every `defineStack()` config, so this stays `undefined` on + * every boot shape that ships today. + * + * Constructed in `init()` only when there is something to register, so a + * boot with no consent record allocates nothing and behaves byte-for-byte + * as it did. Public and readonly-by-accessor so the materialize seam that + * will QUERY it — and a composition pin — can reach the registry rather + * than rebuilding it from the artifact a second time. + */ + private grantEnforcer?: PluginPermissionEnforcer; + /** What `grantedPermissions` bound to on this artifact — see {@link ArtifactGrantBinding}. */ + private grantBindingResult?: ArtifactGrantBinding; /** When true, init/start become no-ops — env has no app payload. */ private readonly empty: boolean = false; /** @@ -171,6 +197,29 @@ export class AppPlugin implements Plugin { return (this.resolvedCollections ??= resolveArtifactCollections(this.bundle)); } + /** + * The enforcer this artifact's consent records were registered on, or + * `undefined` when the artifact declared no `grantedPermissions` key. + * + * ⛔ `undefined` here means "no consent record for this environment", NEVER + * "denied": a caller that reads `undefined` as a deny bricks every boot + * shape that ships today (clause 1.3). The three states this distinguishes + * are written out on `registerArtifactGrantedPermissions`. + */ + get permissionEnforcer(): PluginPermissionEnforcer | undefined { + return this.grantEnforcer; + } + + /** + * What the artifact's `grantedPermissions` map bound to on this boot — + * `undefined` when the key was absent. Public so a composition pin can read + * the binding without re-deriving it, and so a caller can tell a declared + * empty map (`declared: true`, nothing gated) from an absent one. + */ + get grantBinding(): ArtifactGrantBinding | undefined { + return this.grantBindingResult; + } + constructor( bundle: any, projectContext?: AppPluginProjectContext, @@ -264,6 +313,16 @@ export class AppPlugin implements Plugin { // empty-env early return, so teardown is armed on every path init // takes. this.initCtx = ctx; + // Bind the install-time GRANTED permission set (ADR-0025 F4, #13457) + // BEFORE anything this plugin registers on the kernel. This is the + // materialize-time moment the artifact contract names as the consumer + // of `EnvironmentArtifactSchema.grantedPermissions`, and it runs ahead + // of the empty-env return on purpose: a consent record that binds to + // nothing has to be heard on an empty environment too, which is exactly + // where an artifact carrying grants for packages it does not ship shows + // up. A no-op — not even an allocation — on every artifact that carries + // no `grantedPermissions` key. + this.bindGrantedPermissions(ctx); // Install the engine-wide default hook body runner FIRST — even for // empty envs (an empty env is exactly where a user will author their // first Studio hook). Runs in init (Phase 1) so it is in place before @@ -326,6 +385,38 @@ export class AppPlugin implements Plugin { ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload); } + /** + * Register the install-time GRANTED permission set this artifact carries, + * one entry per consent-bearing package, on an enforcer this plugin owns + * (ADR-0025 F4 / #13457 — the consumer half of + * `EnvironmentArtifactSchema.grantedPermissions`). + * + * The whole method is behind the `=== undefined` gate below, and that gate + * is the clause-1.3 guarantee in code: an artifact with no consent record + * takes no branch, allocates no enforcer and registers nothing, so ABSENT + * can never become "denied". `{}` is not absent and does not take the early + * return — a declared-but-empty map is a consent record that names no + * package, which is a different reading and is recorded as one. + * + * ⛔ Never `??`/`||` on `grantedPermissions`: both spellings turn a declared + * `{}` into absence and erase a distinction the producer pins both ways. + */ + private bindGrantedPermissions(ctx: PluginContext): void { + if ((this.bundle as { grantedPermissions?: unknown } | null | undefined)?.grantedPermissions === undefined) { + return; + } + const enforcer = createPluginPermissionEnforcer(ctx.logger); + const binding = registerArtifactGrantedPermissions(this.bundle, enforcer, { logger: ctx.logger }); + this.grantEnforcer = enforcer; + this.grantBindingResult = binding; + ctx.logger.info('[AppPlugin] registered install-time granted permissions', { + pluginName: this.name, + gated: [...binding.gated], + ungated: [...binding.ungated], + unbound: [...binding.unbound], + }); + } + /** * Seed persisted package disable-state into the registry's initial-disabled * set, so every later registration path — boot artifact decomposition, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e425a8a235..0a21232ca2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -140,6 +140,10 @@ export { type RateLimitKeyKind, type RateLimitLogger, type ActorUser, + carriedPackageIds, + resolveArtifactGrantBinding, + registerArtifactGrantedPermissions, + type ArtifactGrantBinding, } from './security/index.js'; // ── Observability primitives ────────────────────────────────────────── diff --git a/packages/runtime/src/security/artifact-granted-permissions.test.ts b/packages/runtime/src/security/artifact-granted-permissions.test.ts new file mode 100644 index 0000000000..59831da62a --- /dev/null +++ b/packages/runtime/src/security/artifact-granted-permissions.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13457 — the artifact→enforcer seam: `EnvironmentArtifactSchema.grantedPermissions` +// reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize +// time, keyed per package by the plugin manifest `id`. +// +// The load-bearing half of this file is the THREE-STATE pin. The producer pins +// absent ≠ `{}` in both directions and the reviewer confirmed three separate +// collapses each go red on its side; a consumer that collapses them re-opens a +// decided question, and one of the collapses (registering `undefined` for a +// package the map does not name) BRICKS BOOT, because +// `buildPermissionsFromGrants(undefined)` denies everything and +// `checkPermission` denies an unregistered plugin too. So every state is +// asserted through the enforcer's OWN readback (`getPluginPermissions`), never +// through the binding record alone — the binding record is what this module +// says it did, the enforcer is what actually happened. + +import { describe, it, expect, vi } from 'vitest'; +import { createPluginPermissionEnforcer } from '@objectstack/core'; +import { + carriedPackageIds, + registerArtifactGrantedPermissions, + resolveArtifactGrantBinding, +} from './artifact-granted-permissions.js'; + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }); +const enforcer = () => createPluginPermissionEnforcer(logger() as never); + +/** A package body as an assembled artifact carries it (ADR-0130 D4). */ +const body = (id: string, extra: Record = {}) => ({ + id, + name: id, + version: '1.0.0', + type: 'module', + ...extra, +}); + +/** A two-package option-B artifact, plus whatever envelope keys the case needs. */ +const artifact = (extra: Record = {}) => ({ + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }, + packages: [{ manifest: body('com.acme.crm') }, { manifest: body('com.acme.reports') }], + ...extra, +}); + +const CONSENTED = { services: ['object'], hooks: ['record.beforeInsert'], network: [], fs: [] }; + +describe('#13457 — the artifact carries the package ids the grant map is keyed by', () => { + it('names every package of an option-B artifact, in registration order', () => { + expect(carriedPackageIds(artifact())).toEqual(['com.acme.crm', 'com.acme.reports']); + }); + + it('names the single package of an artifact with no `packages[]`', () => { + // The id lives one level down on this shape — the same `bundle.manifest + // || bundle` unwrap AppPlugin's constructor performs. Reading the + // artifact's own top level here would return `undefined` and silently + // gate nothing on every single-package artifact. + expect(carriedPackageIds({ manifest: { id: 'com.acme.solo' } })).toEqual(['com.acme.solo']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#13457 — absent, `{}`, and consented are THREE states, never two', () => { + it('ABSENT key registers nothing — an artifact with no consent record is untouched', () => { + const e = enforcer(); + const binding = registerArtifactGrantedPermissions(artifact(), e); + + expect(binding.declared).toBe(false); + expect(binding.gated).toEqual([]); + // ⭐ The clause-1.3 pin: nothing is registered, so nothing is denied. + // The collapse this catches — looping over the CARRIED packages and + // registering `grants[id]` for each — would register `undefined` here + // and hand back a deny-everything bag for both packages. + expect(e.getPluginPermissions('com.acme.crm')).toBeUndefined(); + expect(e.getPluginPermissions('com.acme.reports')).toBeUndefined(); + }); + + it('DECLARED-BUT-EMPTY map is not absence — it is a consent record naming no package', () => { + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: {} }), + enforcer(), + ); + // Same registrations as the absent case (none), a DIFFERENT reading. + // `declared` is the only thing that separates them, which is why it is + // on the record at all. + expect(binding.declared).toBe(true); + expect(binding.gated).toEqual([]); + expect(binding.ungated).toEqual(['com.acme.crm', 'com.acme.reports']); + }); + + it('a `{}` ENTRY is a consent record that consented to nothing — registered, and denies', () => { + const e = enforcer(); + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.crm': {} } }), + e, + ); + + expect(binding.gated).toEqual(['com.acme.crm']); + const perms = e.getPluginPermissions('com.acme.crm'); + // ⭐ DEFINED — the discriminator against the absent case one test up, + // where the identical read is `undefined`. Both deny; only one of them + // is a decision the installer made. + expect(perms).toBeDefined(); + expect(perms!.canAccessService('object')).toBe(false); + expect(perms!.canTriggerHook('record.beforeInsert')).toBe(false); + expect(perms!.canNetworkRequest('https://api.acme.com/x')).toBe(false); + expect(perms!.canReadFile('/tmp/x')).toBe(false); + }); + + it('a CONSENTED entry enforces exactly the consented surface and nothing beside it', () => { + const e = enforcer(); + registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.crm': CONSENTED } }), + e, + ); + + const perms = e.getPluginPermissions('com.acme.crm')!; + expect(perms.canAccessService('object')).toBe(true); + expect(perms.canAccessService('storage')).toBe(false); + expect(perms.canTriggerHook('record.beforeInsert')).toBe(true); + expect(perms.canTriggerHook('record.afterDelete')).toBe(false); + // `network: []` and `fs: []` are consented-to-nothing, not unconstrained. + expect(perms.canNetworkRequest('https://api.acme.com/x')).toBe(false); + expect(perms.canWriteFile('/tmp/x')).toBe(false); + }); + + it('a package the map does NOT name stays ungated while its sibling is gated', () => { + // ⭐ The boot-brick pin, and the reason the walk reads the MAP's keys + // rather than the package list: a first-party package sharing an + // artifact with a consent-bearing one must keep loading exactly as it + // does today. + const e = enforcer(); + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.crm': CONSENTED } }), + e, + ); + + expect(binding.gated).toEqual(['com.acme.crm']); + expect(binding.ungated).toEqual(['com.acme.reports']); + expect(e.getPluginPermissions('com.acme.reports')).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#13457 — a consent record that binds to nothing is said out loud', () => { + it('reports an entry naming a package this artifact does not carry, and does not register it', () => { + const e = enforcer(); + const log = logger(); + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.ghost': CONSENTED } }), + e, + { logger: log as never }, + ); + + expect(binding.unbound).toEqual(['com.acme.ghost']); + expect(binding.gated).toEqual([]); + expect(e.getPluginPermissions('com.acme.ghost')).toBeUndefined(); + expect( + log.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), + ).toBe(true); + }); + + it('a map that is not a record at all reads as declared-with-nothing-bound', () => { + const binding = resolveArtifactGrantBinding(artifact({ grantedPermissions: [] })); + expect(binding.declared).toBe(true); + expect(binding.gated).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#13457 — the unattributable-consent case cannot reach this seam', () => { + // The artifact contract has no spelling for "a consent record exists but + // cannot be attributed": when a manifest carries no top-level string `id` + // the producer emits it under NO name, so to a consumer that case is + // indistinguishable from "no consent record". This pin records WHY the + // consumer never has to choose a behaviour for it: a package with no usable + // id is refused BEFORE any grant question, by the platform's own package + // sorter — and `ManifestSchema.id` is a required `z.string()`, so + // `ArtifactPackageSchema` refuses the same shape one door earlier. + it('a `packages[]` entry with no usable id is refused, not silently ungated', () => { + expect(() => carriedPackageIds({ packages: [{ manifest: { name: '', version: '1.0.0' } }] })) + .toThrow(/no usable package id|not a package entry/); + }); + + it('an empty-string id is refused too — `\'\'` is not a key anything can be attributed to', () => { + expect(() => carriedPackageIds({ packages: [{ manifest: body('') }] })) + .toThrow(/no usable package id|not a package entry/); + }); +}); diff --git a/packages/runtime/src/security/artifact-granted-permissions.ts b/packages/runtime/src/security/artifact-granted-permissions.ts new file mode 100644 index 0000000000..125e547efd --- /dev/null +++ b/packages/runtime/src/security/artifact-granted-permissions.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The artifact→enforcer seam: bind the install-time GRANTED permission set an + * environment artifact carries to the plugins that artifact materializes + * (ADR-0025 §3.5 step 2 / F4, #11333 option A phase 1). + * + * ## What this is the consumer half of + * + * `EnvironmentArtifactSchema.grantedPermissions` + * (`packages/spec/src/system/environment-artifact.zod.ts`) names its consumer + * verbatim: *"the materialize-time loader, which hands each entry to + * `PluginPermissionEnforcer.registerGrantedPermissions(pluginName, granted)`"*. + * This module is that loader half, and `AppPlugin.init()` is its one production + * caller — the single point where an environment artifact becomes a kernel + * plugin, on the self-hosted path (`createStandaloneStack`) and on the cloud + * path (`ArtifactKernelFactory`, which constructs the same `AppPlugin`) alike. + * + * ## The binding this creates, and why it did not exist before + * + * The map is keyed by the PLUGIN MANIFEST `id` — `com.acme.crm`, the name + * `artifactPackageId()` gives a package and the name the enforcer is queried + * with. One `AppPlugin` covers the WHOLE artifact and registers under a single + * kernel plugin name derived from `manifest.id`, so before this module nothing + * in the load path could say which of an artifact's packages a given grant + * entry belonged to. The walk below is that key-to-plugin binding: it resolves + * the artifact's carried package ids through the platform's one package sorter + * (`resolveArtifactPackageOrder`, ADR-0130 D4/D5) and registers each entry + * under the id the sorter names, so a grant entry is bound to a package the + * artifact actually carries or it is bound to nothing and said so out loud. + * + * ## ⛔ Absent is NOT `{}`, in BOTH directions — three states, never two + * + * The producer pins all three and the contract forbids collapsing them, so the + * walk below is driven by the map's OWN KEYS and never by the package list: + * + * 1. the artifact carries NO `grantedPermissions` key — no consent record + * exists for this environment (first-party stacks, pure-metadata packages, + * every artifact built before consent existed). ⇒ NOTHING is registered, + * no enforcer is even constructed, and every package loads exactly as it + * does today. `declared: false`. + * 2. the key is present and names this package with `{}` — a consent record + * that consented to NOTHING. ⇒ registered, and `buildPermissionsFromGrants` + * turns it into a bag that denies every service, hook, host and path. + * 3. the key is present and does NOT name this package — same as (1) FOR + * THAT PACKAGE: no entry, no registration, no gate. + * + * ⛔ The tempting spelling — `for (const id of carried) + * enforcer.registerGrantedPermissions(id, grants[id])` — collapses (3) into (2) + * silently: `registerGrantedPermissions(id, undefined)` registers a DENY-ALL + * bag, so every first-party plugin in the artifact would come up denied. That + * is the boot brick clause 1.3 exists to forbid, and it is one keystroke away + * from the loop below. Read the keys, never the packages. + * + * ## What this does NOT do + * + * It registers the consented set; it does not intercept access. Every + * enforcement surface `PluginPermissionEnforcer` exposes is reached through + * `SecurePluginContext` — per-plugin context construction, i.e. the ADR-0025 + * materialize seam, which is not this module's to improvise. So an entry + * registered here is queried by nothing on this tree yet; the binding is the + * half that had to exist first, and the registry it fills is the thing the + * materialize seam reads. + */ + +import { + artifactPackageId, + resolveArtifactPackageOrder, + type PluginPermissionEnforcer, +} from '@objectstack/core'; +import type { Logger } from '@objectstack/spec/contracts'; +import type { PluginPermissions as GrantedPermissions } from '@objectstack/spec/kernel'; + +/** What one artifact's granted-permission map bound to. */ +export interface ArtifactGrantBinding { + /** + * Did the artifact carry a `grantedPermissions` key at all? `false` is + * state (1) above — no consent record — and is NOT the same reading as a + * declared but empty map, which is `true` with an empty {@link gated}. + */ + readonly declared: boolean; + /** Package ids this artifact carries, in the order the loader registers them. */ + readonly carried: readonly string[]; + /** Ids that carried a consent record and are now registered on the enforcer. */ + readonly gated: readonly string[]; + /** Carried ids with NO consent record — ungated, byte-for-byte today's behaviour. */ + readonly ungated: readonly string[]; + /** + * Map keys naming a package this artifact does NOT carry. The producer pins + * that this is empty (*"the map never names a plugin the artifact does not + * carry"*), so a non-empty list is a carrier fault: a consented set that + * binds to nothing is a security control silently absent. + */ + readonly unbound: readonly string[]; +} + +const EMPTY: ArtifactGrantBinding = { + declared: false, carried: [], gated: [], ungated: [], unbound: [], +}; + +/** True for a plain object — the only shape `z.record(...)` produces. */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The package ids one artifact carries, named exactly as the rest of the + * platform names them. + * + * `resolveArtifactPackageOrder` returns each package's manifest BODY for a + * `packages[]` artifact and the artifact itself for a single-package one, so + * the id lives at different depths on the two shapes. `body.manifest ?? body` + * is the same unwrap `AppPlugin`'s constructor performs (`bundle.manifest || + * bundle`), which is what keeps this list equal to the names the plugin + * actually registers under. + */ +export function carriedPackageIds(artifact: unknown): string[] { + const bodies = resolveArtifactPackageOrder(artifact) as Array | null | undefined>; + const out: string[] = []; + for (const body of bodies) { + const sys = (body as { manifest?: unknown } | null | undefined)?.manifest ?? body; + const id = artifactPackageId(sys); + if (id !== undefined && !out.includes(id)) out.push(id); + } + return out; +} + +/** + * Read the artifact's granted-permission map WITHOUT touching an enforcer. + * + * Pure, so the three-state distinction above can be pinned on its own, and so + * the caller can decide whether to construct an enforcer at all. + */ +export function resolveArtifactGrantBinding(artifact: unknown): ArtifactGrantBinding { + const grants = (artifact as { grantedPermissions?: unknown } | null | undefined)?.grantedPermissions; + // ⛔ `=== undefined`, never a falsy test: `{}` is a consent record that + // consented to nothing and must read as DECLARED. + if (grants === undefined) return EMPTY; + if (!isPlainRecord(grants)) { + // Not a shape `z.record(z.string(), PluginPermissionsSchema)` can + // produce. Reported as declared-with-nothing-bound rather than silently + // treated as absent: a carrier that ships garbage here is a carrier + // whose consent records are gone. + return { declared: true, carried: [], gated: [], ungated: [], unbound: [] }; + } + const carried = carriedPackageIds(artifact); + const gated: string[] = []; + const unbound: string[] = []; + // Driven by the MAP's keys — see the header. `Object.keys` reads own + // enumerable keys only, so nothing on `Object.prototype` can fabricate an + // entry, and a key present with any value (`{}` included) is an entry. + for (const key of Object.keys(grants)) { + if (carried.includes(key)) gated.push(key); + else unbound.push(key); + } + return { + declared: true, + carried, + gated, + ungated: carried.filter((id) => !gated.includes(id)), + unbound, + }; +} + +/** + * Bind an artifact's granted-permission map onto `enforcer`, one + * `registerGrantedPermissions` call per consent record the artifact carries. + * + * @returns What bound — see {@link ArtifactGrantBinding}. On an artifact with no + * `grantedPermissions` key this registers nothing and returns `declared: + * false`; the enforcer is left exactly as it was handed over. + */ +export function registerArtifactGrantedPermissions( + artifact: unknown, + enforcer: PluginPermissionEnforcer, + opts: { logger?: Logger } = {}, +): ArtifactGrantBinding { + const binding = resolveArtifactGrantBinding(artifact); + if (!binding.declared) return binding; + + const grants = (artifact as { grantedPermissions?: Record }).grantedPermissions ?? {}; + for (const id of binding.gated) { + // The VALUE as the carrier wrote it: `{}` registers a deny-everything + // bag, a populated entry registers exactly the consented surface. ⛔ No + // `?? {}` and no default — both spellings would erase the distinction + // the producer pins. + enforcer.registerGrantedPermissions(id, grants[id] as GrantedPermissions); + } + + if (binding.unbound.length > 0) { + // Absence must be loud (Route & surface ownership §3). This is the + // "consented set silently fails to bind" failure the artifact contract + // names as the producer's residual risk; the consumer refuses to let it + // pass in silence even though it cannot repair it. + opts.logger?.warn( + '[grantedPermissions] consent records bound to NO package carried by this artifact — ' + + 'the consented surface for them is enforced nowhere', + { unbound: [...binding.unbound], carried: [...binding.carried] }, + ); + } + return binding; +} diff --git a/packages/runtime/src/security/index.ts b/packages/runtime/src/security/index.ts index c8d5a530ed..92e23d585a 100644 --- a/packages/runtime/src/security/index.ts +++ b/packages/runtime/src/security/index.ts @@ -45,3 +45,15 @@ export { type GeneratedApiKey, type ApiKeyPrincipal, } from './api-key.js'; +// The artifact→enforcer seam (ADR-0025 F4 / #13457): binds the install-time +// GRANTED permission set an environment artifact carries to the packages that +// artifact materializes. Published because the cloud control plane's +// `ArtifactKernelFactory` boots the same artifacts through this package, and +// because the ADR-0025 materialize seam that will QUERY the registry is a +// different card in a different package. +export { + carriedPackageIds, + resolveArtifactGrantBinding, + registerArtifactGrantedPermissions, + type ArtifactGrantBinding, +} from './artifact-granted-permissions.js'; From 8d6bd628e00ebf16e6b1c88e837694ea8b33f734 Mon Sep 17 00:00:00 2001 From: os-sam Date: Wed, 9 Sep 2026 12:16:06 +0000 Subject: [PATCH 2/4] chore(changeset): declare the artifact granted-permissions load binding Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude --- .../artifact-granted-permissions-load-binding.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/artifact-granted-permissions-load-binding.md diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md new file mode 100644 index 0000000000..6d203565ac --- /dev/null +++ b/.changeset/artifact-granted-permissions-load-binding.md @@ -0,0 +1,13 @@ +--- +'@objectstack/runtime': minor +--- + +Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. + +`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). + +Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. + +New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. + +This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. From ef3455756db0c143c5591f8b8562ec2a80162d91 Mon Sep 17 00:00:00 2001 From: os-sam Date: Wed, 9 Sep 2026 12:27:43 +0000 Subject: [PATCH 3/4] fix(runtime): carry `grantedPermissions` across the artifact envelope unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `{ schemaVersion, metadata }` unwrap hands the kernel `metadata` alone and drops every key beside it, so the install-time consented set — which the artifact contract puts BESIDE `metadata` — never reached the loader that the contract names as its consumer. Silent, and indistinguishable from the legitimate "no consent record" reading. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude --- ...tifact-granted-permissions-load-binding.md | 2 + ...rtifact-bundle.granted-permissions.test.ts | 70 +++++++++++++++++++ packages/runtime/src/load-artifact-bundle.ts | 23 +++++- 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/load-artifact-bundle.granted-permissions.test.ts diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md index 6d203565ac..e39281c242 100644 --- a/.changeset/artifact-granted-permissions-load-binding.md +++ b/.changeset/artifact-granted-permissions-load-binding.md @@ -8,6 +8,8 @@ Bind an environment artifact's install-time GRANTED permission set to the packag Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. +Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. + New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. diff --git a/packages/runtime/src/load-artifact-bundle.granted-permissions.test.ts b/packages/runtime/src/load-artifact-bundle.granted-permissions.test.ts new file mode 100644 index 0000000000..d016ed953a --- /dev/null +++ b/packages/runtime/src/load-artifact-bundle.granted-permissions.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13457 — the `{ schemaVersion, metadata }` unwrap must carry +// `grantedPermissions` across. +// +// `EnvironmentArtifactSchema` puts the install-time consented set BESIDE +// `metadata`, and the unwrap hands the kernel `metadata` alone — so every key +// standing beside it is dropped. For `grantedPermissions` that loss is SILENT +// and indistinguishable from the legitimate reading: an absent key means "no +// consent record", so an envelope stripped of its consent records boots green, +// enforcing nothing, with nothing to see. That is why this is pinned on the +// loader rather than left to the consumer. + +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadArtifactBundle } from './load-artifact-bundle.js'; + +const write = (body: unknown): string => { + const dir = mkdtempSync(join(tmpdir(), 'os-13457-')); + const file = join(dir, 'objectstack.json'); + writeFileSync(file, JSON.stringify(body), 'utf-8'); + return file; +}; + +const envelope = (extra: Record) => ({ + schemaVersion: '0.1', + environmentId: 'env_1', + commitId: 'c1', + checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + metadata: { manifest: { id: 'com.acme.crm', name: 'crm', version: '1.0.0', type: 'app' } }, + ...extra, +}); + +describe('#13457 — the envelope unwrap carries the consented set', () => { + it('a populated `grantedPermissions` survives the unwrap', async () => { + const grants = { 'com.acme.crm': { services: ['object'], hooks: [], network: [], fs: [] } }; + const bundle = await loadArtifactBundle(write(envelope({ grantedPermissions: grants })), { + unwrapEnvelope: true, + }); + // The unwrapped bundle is `metadata`; the consented set rode across. + expect(bundle.manifest.id).toBe('com.acme.crm'); + expect(bundle.grantedPermissions).toEqual(grants); + }); + + it('a declared EMPTY map survives as `{}` — not as absence', async () => { + const bundle = await loadArtifactBundle(write(envelope({ grantedPermissions: {} })), { + unwrapEnvelope: true, + }); + // ⭐ `{}` is a consent record that names no package. A truthiness or + // emptiness test in the carry would drop it and turn it into the absent + // reading, which is the collapse the producer pins against. + expect(bundle.grantedPermissions).toEqual({}); + expect('grantedPermissions' in bundle).toBe(true); + }); + + it('an envelope with NO `grantedPermissions` does not grow one', async () => { + const bundle = await loadArtifactBundle(write(envelope({})), { unwrapEnvelope: true }); + expect('grantedPermissions' in bundle).toBe(false); + }); + + it('an UNWRAPPED artifact is untouched — the carry is the unwrap\'s business only', async () => { + // No `schemaVersion`, so nothing unwraps and the parsed object is the + // bundle; the key (present or not) is already where the consumer reads. + const flat = { manifest: { id: 'com.acme.crm' }, grantedPermissions: { 'com.acme.crm': {} } }; + const bundle = await loadArtifactBundle(write(flat), { unwrapEnvelope: true }); + expect(bundle.grantedPermissions).toEqual({ 'com.acme.crm': {} }); + }); +}); diff --git a/packages/runtime/src/load-artifact-bundle.ts b/packages/runtime/src/load-artifact-bundle.ts index 3aff1a94bf..bea0ad56be 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -85,9 +85,26 @@ export async function loadArtifactBundle( try { const raw = await readArtifactSource(absArtifactPath, { fetchTimeoutMs: opts.fetchTimeoutMs }); const parsed = JSON.parse(raw); - bundle = opts.unwrapEnvelope && parsed?.schemaVersion != null && parsed?.metadata !== undefined - ? parsed.metadata - : parsed; + const unwrapping = Boolean(opts.unwrapEnvelope) + && parsed?.schemaVersion != null && parsed?.metadata !== undefined; + bundle = unwrapping ? parsed.metadata : parsed; + // [#13457] The unwrap keeps `metadata` and DROPS every key standing + // beside it, so an envelope key whose declared consumer is this loader + // has to be carried across by name or it is silently gone. + // `grantedPermissions` is that key: `EnvironmentArtifactSchema` puts the + // install-time consented set BESIDE `metadata` (outside the checksum + // digest), and names the materialize-time loader as its consumer — so + // before this line an envelope artifact reached the kernel with every + // consent record stripped, and the gate had nothing to enforce. Nothing + // threw: the key was simply absent, which is also a legitimate reading + // ("no consent record"), which is why it could be lost in silence. + // ⛔ `!== undefined`, never a truthiness or emptiness test: `{}` is a + // consent record that consented to nothing and must survive the unwrap + // as `{}`, while a genuinely absent key must NOT be created here. + if (unwrapping && parsed.grantedPermissions !== undefined + && bundle !== null && typeof bundle === 'object') { + bundle.grantedPermissions = parsed.grantedPermissions; + } } catch (err: any) { // An ABSENT artifact is not a failure (#4085). The platform is a // development platform first: `os serve objectstack.config.ts` boots From 0ec3026f72995975843f43984659f268525663d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:47:01 +0000 Subject: [PATCH 4/4] fix(runtime): name the grant binding for what it does, and drop the forbidden `?? {}` Contract-review rework on #13457. Behaviour is unchanged in every leg; what moves is naming, one redundant default, and two test assertions that could not tell the doors apart. - `ArtifactGrantBinding.gated` / `ungated` -> `registered` / `unregistered`, and the `[AppPlugin]` log line with them. The old names claimed a gate that does not exist: this round REGISTERS a consent set and nothing on this tree queries the registry, which the type's own docblock already said ("registered on the enforcer"). The loader comment "the gate had nothing to enforce" presupposed the same absent gate. - Delete `.grantedPermissions ?? {}` in `registerArtifactGrantedPermissions`. It was a semantic no-op, but it is the exact spelling `app-plugin.ts` forbids by name on this key. Replaced by the narrowing that states the fact the walk already established: `registered` is non-empty only on the plain-record branch, so the loop reads the carrier's own record or iterates nothing. - Correct the "closed by two doors" claim. `{ id: '', name: 'x' }` passes BOTH doors, because `artifactPackageId` is `id || name`; the fixture set id and name to `''` together and hid the fallback. The escaping case is now pinned, including its fail-OPEN reading through the enforcer's own readback: the unattributable `''` key binds to nothing, is reported `unbound`, and denies nothing. No refusal is added -- that fork is #17148. - Tighten both door tests. Each asserted `/no usable package id|not a package entry/`, so either passed on either door and neither pinned which fired. Both doors raise the same ADR-0112 code and status, so each test now pins the shared envelope plus the message unique to its own door, and asserts the other door's message is absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../app-plugin.granted-permissions.test.ts | 8 +- packages/runtime/src/app-plugin.ts | 6 +- packages/runtime/src/load-artifact-bundle.ts | 9 +- .../artifact-granted-permissions.test.ts | 119 ++++++++++++++---- .../security/artifact-granted-permissions.ts | 58 ++++++--- 5 files changed, 146 insertions(+), 54 deletions(-) diff --git a/packages/runtime/src/app-plugin.granted-permissions.test.ts b/packages/runtime/src/app-plugin.granted-permissions.test.ts index eee20c008b..29f206cb81 100644 --- a/packages/runtime/src/app-plugin.granted-permissions.test.ts +++ b/packages/runtime/src/app-plugin.granted-permissions.test.ts @@ -69,12 +69,12 @@ describe('#13457 — AppPlugin.init binds the artifact\'s granted permissions', expect(plugin.name).toBe('plugin.app.com.acme.crm'); expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('object')).toBe(true); expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('storage')).toBe(false); - // The sibling package carries no consent record — ungated, unregistered. + // The sibling package carries no consent record — nothing registered for it. expect(e!.getPluginPermissions('com.acme.reports')).toBeUndefined(); expect(plugin.grantBinding).toMatchObject({ declared: true, - gated: ['com.acme.crm'], - ungated: ['com.acme.reports'], + registered: ['com.acme.crm'], + unregistered: ['com.acme.reports'], unbound: [], }); }); @@ -90,7 +90,7 @@ describe('#13457 — AppPlugin.init binds the artifact\'s granted permissions', }); await plugin.init(ctx); - expect(plugin.grantBinding).toMatchObject({ declared: true, gated: [], unbound: ['com.acme.ghost'] }); + expect(plugin.grantBinding).toMatchObject({ declared: true, registered: [], unbound: ['com.acme.ghost'] }); expect( ctx.logger.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), ).toBe(true); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 37b9c37559..e80e8d5d82 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -214,7 +214,7 @@ export class AppPlugin implements Plugin { * What the artifact's `grantedPermissions` map bound to on this boot — * `undefined` when the key was absent. Public so a composition pin can read * the binding without re-deriving it, and so a caller can tell a declared - * empty map (`declared: true`, nothing gated) from an absent one. + * empty map (`declared: true`, nothing registered) from an absent one. */ get grantBinding(): ArtifactGrantBinding | undefined { return this.grantBindingResult; @@ -411,8 +411,8 @@ export class AppPlugin implements Plugin { this.grantBindingResult = binding; ctx.logger.info('[AppPlugin] registered install-time granted permissions', { pluginName: this.name, - gated: [...binding.gated], - ungated: [...binding.ungated], + registered: [...binding.registered], + unregistered: [...binding.unregistered], unbound: [...binding.unbound], }); } diff --git a/packages/runtime/src/load-artifact-bundle.ts b/packages/runtime/src/load-artifact-bundle.ts index bea0ad56be..d5f0c6e8e7 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -95,9 +95,12 @@ export async function loadArtifactBundle( // install-time consented set BESIDE `metadata` (outside the checksum // digest), and names the materialize-time loader as its consumer — so // before this line an envelope artifact reached the kernel with every - // consent record stripped, and the gate had nothing to enforce. Nothing - // threw: the key was simply absent, which is also a legitimate reading - // ("no consent record"), which is why it could be lost in silence. + // consent record stripped, and the enforcer had nothing to register. + // (⛔ Not "the gate had nothing to enforce": there is no gate — this + // round fills the registry, and nothing on this tree queries it yet.) + // Nothing threw: the key was simply absent, which is also a legitimate + // reading ("no consent record"), which is why it could be lost in + // silence. // ⛔ `!== undefined`, never a truthiness or emptiness test: `{}` is a // consent record that consented to nothing and must survive the unwrap // as `{}`, while a genuinely absent key must NOT be created here. diff --git a/packages/runtime/src/security/artifact-granted-permissions.test.ts b/packages/runtime/src/security/artifact-granted-permissions.test.ts index 59831da62a..16ee8322f7 100644 --- a/packages/runtime/src/security/artifact-granted-permissions.test.ts +++ b/packages/runtime/src/security/artifact-granted-permissions.test.ts @@ -53,7 +53,7 @@ describe('#13457 — the artifact carries the package ids the grant map is keyed // The id lives one level down on this shape — the same `bundle.manifest // || bundle` unwrap AppPlugin's constructor performs. Reading the // artifact's own top level here would return `undefined` and silently - // gate nothing on every single-package artifact. + // register nothing on every single-package artifact. expect(carriedPackageIds({ manifest: { id: 'com.acme.solo' } })).toEqual(['com.acme.solo']); }); }); @@ -65,7 +65,7 @@ describe('#13457 — absent, `{}`, and consented are THREE states, never two', ( const binding = registerArtifactGrantedPermissions(artifact(), e); expect(binding.declared).toBe(false); - expect(binding.gated).toEqual([]); + expect(binding.registered).toEqual([]); // ⭐ The clause-1.3 pin: nothing is registered, so nothing is denied. // The collapse this catches — looping over the CARRIED packages and // registering `grants[id]` for each — would register `undefined` here @@ -83,8 +83,8 @@ describe('#13457 — absent, `{}`, and consented are THREE states, never two', ( // `declared` is the only thing that separates them, which is why it is // on the record at all. expect(binding.declared).toBe(true); - expect(binding.gated).toEqual([]); - expect(binding.ungated).toEqual(['com.acme.crm', 'com.acme.reports']); + expect(binding.registered).toEqual([]); + expect(binding.unregistered).toEqual(['com.acme.crm', 'com.acme.reports']); }); it('a `{}` ENTRY is a consent record that consented to nothing — registered, and denies', () => { @@ -94,7 +94,7 @@ describe('#13457 — absent, `{}`, and consented are THREE states, never two', ( e, ); - expect(binding.gated).toEqual(['com.acme.crm']); + expect(binding.registered).toEqual(['com.acme.crm']); const perms = e.getPluginPermissions('com.acme.crm'); // ⭐ DEFINED — the discriminator against the absent case one test up, // where the identical read is `undefined`. Both deny; only one of them @@ -123,7 +123,7 @@ describe('#13457 — absent, `{}`, and consented are THREE states, never two', ( expect(perms.canWriteFile('/tmp/x')).toBe(false); }); - it('a package the map does NOT name stays ungated while its sibling is gated', () => { + it('a package the map does NOT name stays unregistered while its sibling is registered', () => { // ⭐ The boot-brick pin, and the reason the walk reads the MAP's keys // rather than the package list: a first-party package sharing an // artifact with a consent-bearing one must keep loading exactly as it @@ -134,8 +134,8 @@ describe('#13457 — absent, `{}`, and consented are THREE states, never two', ( e, ); - expect(binding.gated).toEqual(['com.acme.crm']); - expect(binding.ungated).toEqual(['com.acme.reports']); + expect(binding.registered).toEqual(['com.acme.crm']); + expect(binding.unregistered).toEqual(['com.acme.reports']); expect(e.getPluginPermissions('com.acme.reports')).toBeUndefined(); }); }); @@ -152,7 +152,7 @@ describe('#13457 — a consent record that binds to nothing is said out loud', ( ); expect(binding.unbound).toEqual(['com.acme.ghost']); - expect(binding.gated).toEqual([]); + expect(binding.registered).toEqual([]); expect(e.getPluginPermissions('com.acme.ghost')).toBeUndefined(); expect( log.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), @@ -162,27 +162,96 @@ describe('#13457 — a consent record that binds to nothing is said out loud', ( it('a map that is not a record at all reads as declared-with-nothing-bound', () => { const binding = resolveArtifactGrantBinding(artifact({ grantedPermissions: [] })); expect(binding.declared).toBe(true); - expect(binding.gated).toEqual([]); + expect(binding.registered).toEqual([]); }); }); // ─────────────────────────────────────────────────────────────────────────── -describe('#13457 — the unattributable-consent case cannot reach this seam', () => { - // The artifact contract has no spelling for "a consent record exists but - // cannot be attributed": when a manifest carries no top-level string `id` - // the producer emits it under NO name, so to a consumer that case is - // indistinguishable from "no consent record". This pin records WHY the - // consumer never has to choose a behaviour for it: a package with no usable - // id is refused BEFORE any grant question, by the platform's own package - // sorter — and `ManifestSchema.id` is a required `z.string()`, so - // `ArtifactPackageSchema` refuses the same shape one door earlier. - it('a `packages[]` entry with no usable id is refused, not silently ungated', () => { - expect(() => carriedPackageIds({ packages: [{ manifest: { name: '', version: '1.0.0' } }] })) - .toThrow(/no usable package id|not a package entry/); +// The artifact contract has no spelling for "a consent record exists but cannot +// be attributed": when a manifest carries no top-level string `id` the producer +// emits it under NO name, so to a consumer that case is indistinguishable from +// "no consent record". +// +// ⚠️ An earlier revision of this block claimed that case "cannot reach this +// seam", closed by two doors. MEASURED FALSE (#13457 contract review ⑤): the +// doors refuse two spellings and the THIRD — `{ id: '', name: 'x' }` — walks +// through both, because `artifactPackageId` is `id || name`. The fixture hid it +// by setting id and name to `''` together. Corrected here, and the case that +// escapes is pinned rather than described. +// +// ⛔ Each door test pins ITS OWN door. The earlier spelling asserted +// `/no usable package id|not a package entry/` on BOTH, so either test passed on +// either door: it pinned "refused by some door", never which — an alternation +// that would survive deleting a whole door. Both doors raise the SAME ADR-0112 +// code and status, so only the message separates them. +describe('#13457 — which unattributable-consent spellings the doors refuse, and the one they do not', () => { + /** The refusal both doors share, so the message assertions carry the rest. */ + const envelope = (err: any) => { + expect(err).toBeDefined(); + expect(err.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(err.status).toBe(422); + }; + const thrownBy = (fn: () => unknown): any => { + try { fn(); } catch (e) { return e; } + return undefined; + }; + + it('DOOR 1 (schema) — no top-level `id` is refused by `ArtifactPackageSchema`, which names `manifest.id`', () => { + // `ManifestSchema.id` is a required `z.string()`, so the entry never + // reaches the id door at all. + const err = thrownBy(() => carriedPackageIds({ packages: [{ manifest: { name: '', version: '1.0.0' } }] })); + envelope(err); + expect(err.message).toContain('is not a package entry'); + expect(err.message).toContain('manifest.id'); + // ⛔ THIS door, not the other one: the id door never ran. + expect(err.message).not.toContain('no usable package id'); + }); + + it('DOOR 2 (id) — `\'\'` passes the schema and is refused by `artifactPackageId`, one door later', () => { + // `z.string()` has no `.min(1)`, so `''` is a VALID manifest id to the + // schema; it is `artifactPackageId` that yields `undefined` for it. + const err = thrownBy(() => carriedPackageIds({ packages: [{ manifest: body('') }] })); + envelope(err); + expect(err.message).toContain('no usable package id'); + // ⛔ THIS door, not the other one: the schema admitted the entry. + expect(err.message).not.toContain('is not a package entry'); }); - it('an empty-string id is refused too — `\'\'` is not a key anything can be attributed to', () => { - expect(() => carriedPackageIds({ packages: [{ manifest: body('') }] })) - .toThrow(/no usable package id|not a package entry/); + // ⭐ The correction: the spelling NEITHER door refuses. + it('NEITHER door refuses `{ id: \'\', name: \'x\' }` — `artifactPackageId` is `id || name`, so it is carried as `x`', () => { + expect(carriedPackageIds({ packages: [{ manifest: body('', { id: '', name: 'x' }) }] })) + .toEqual(['x']); + }); + + it('so a consent record keyed by the unattributable `\'\'` binds to NOTHING — loudly, and fail-OPEN', () => { + // ⛔ What this pins is that the residual is fail-OPEN, not that it is + // handled: the `''` key names no carried package, so it is reported as + // `unbound` and registered nowhere. Nothing is silently DENIED — the + // package still loads with no consent record at all, exactly as an + // artifact that never declared one does. Whether an unbindable consent + // record should instead REFUSE the artifact is an open decision + // (#17148), and this test is what will go red when it is taken. + const e = enforcer(); + const log = logger(); + const binding = registerArtifactGrantedPermissions( + { + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }, + packages: [{ manifest: body('', { id: '', name: 'x' }) }], + grantedPermissions: { '': CONSENTED }, + }, + e, + { logger: log as never }, + ); + + expect(binding.carried).toEqual(['x']); + expect(binding.registered).toEqual([]); + expect(binding.unbound).toEqual(['']); + // Through the enforcer's OWN readback: neither the unattributable key + // nor the package it failed to name is registered, so neither is denied. + expect(e.getPluginPermissions('')).toBeUndefined(); + expect(e.getPluginPermissions('x')).toBeUndefined(); + expect( + log.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), + ).toBe(true); }); }); diff --git a/packages/runtime/src/security/artifact-granted-permissions.ts b/packages/runtime/src/security/artifact-granted-permissions.ts index 125e547efd..bf2cf9801c 100644 --- a/packages/runtime/src/security/artifact-granted-permissions.ts +++ b/packages/runtime/src/security/artifact-granted-permissions.ts @@ -43,7 +43,7 @@ * that consented to NOTHING. ⇒ registered, and `buildPermissionsFromGrants` * turns it into a bag that denies every service, hook, host and path. * 3. the key is present and does NOT name this package — same as (1) FOR - * THAT PACKAGE: no entry, no registration, no gate. + * THAT PACKAGE: no entry, no registration, nothing on the enforcer. * * ⛔ The tempting spelling — `for (const id of carried) * enforcer.registerGrantedPermissions(id, grants[id])` — collapses (3) into (2) @@ -76,15 +76,28 @@ export interface ArtifactGrantBinding { /** * Did the artifact carry a `grantedPermissions` key at all? `false` is * state (1) above — no consent record — and is NOT the same reading as a - * declared but empty map, which is `true` with an empty {@link gated}. + * declared but empty map, which is `true` with an empty {@link registered}. */ readonly declared: boolean; /** Package ids this artifact carries, in the order the loader registers them. */ readonly carried: readonly string[]; - /** Ids that carried a consent record and are now registered on the enforcer. */ - readonly gated: readonly string[]; - /** Carried ids with NO consent record — ungated, byte-for-byte today's behaviour. */ - readonly ungated: readonly string[]; + /** + * Ids that carried a consent record — the set + * {@link registerArtifactGrantedPermissions} registers on the enforcer, one + * `registerGrantedPermissions` call each. + * + * ⛔ The name says REGISTERED, and must keep saying it. An earlier spelling + * called this `gated`, which claimed an enforcement that does not exist: + * registration is ALL that happens here, nothing on this tree queries the + * registry, and so an id in this list is under no gate whatsoever. Rename it + * back only together with the code that makes the claim true. + */ + readonly registered: readonly string[]; + /** + * Carried ids with NO consent record — nothing is registered for them, and + * they load byte-for-byte as they do today. + */ + readonly unregistered: readonly string[]; /** * Map keys naming a package this artifact does NOT carry. The producer pins * that this is empty (*"the map never names a plugin the artifact does not @@ -95,7 +108,7 @@ export interface ArtifactGrantBinding { } const EMPTY: ArtifactGrantBinding = { - declared: false, carried: [], gated: [], ungated: [], unbound: [], + declared: false, carried: [], registered: [], unregistered: [], unbound: [], }; /** True for a plain object — the only shape `z.record(...)` produces. */ @@ -141,23 +154,23 @@ export function resolveArtifactGrantBinding(artifact: unknown): ArtifactGrantBin // produce. Reported as declared-with-nothing-bound rather than silently // treated as absent: a carrier that ships garbage here is a carrier // whose consent records are gone. - return { declared: true, carried: [], gated: [], ungated: [], unbound: [] }; + return { declared: true, carried: [], registered: [], unregistered: [], unbound: [] }; } const carried = carriedPackageIds(artifact); - const gated: string[] = []; + const registered: string[] = []; const unbound: string[] = []; // Driven by the MAP's keys — see the header. `Object.keys` reads own // enumerable keys only, so nothing on `Object.prototype` can fabricate an // entry, and a key present with any value (`{}` included) is an entry. for (const key of Object.keys(grants)) { - if (carried.includes(key)) gated.push(key); + if (carried.includes(key)) registered.push(key); else unbound.push(key); } return { declared: true, carried, - gated, - ungated: carried.filter((id) => !gated.includes(id)), + registered, + unregistered: carried.filter((id) => !registered.includes(id)), unbound, }; } @@ -178,13 +191,20 @@ export function registerArtifactGrantedPermissions( const binding = resolveArtifactGrantBinding(artifact); if (!binding.declared) return binding; - const grants = (artifact as { grantedPermissions?: Record }).grantedPermissions ?? {}; - for (const id of binding.gated) { - // The VALUE as the carrier wrote it: `{}` registers a deny-everything - // bag, a populated entry registers exactly the consented surface. ⛔ No - // `?? {}` and no default — both spellings would erase the distinction - // the producer pins. - enforcer.registerGrantedPermissions(id, grants[id] as GrantedPermissions); + const grants = (artifact as { grantedPermissions?: unknown } | null | undefined)?.grantedPermissions; + // ⛔ No `?? {}` and no default — both spellings are the erasure this module + // exists to refuse, and `app-plugin.ts` forbids them by name on this very + // key. The narrowing below is the type-honest spelling of a fact the walk + // already established: `registered` is non-empty ONLY on the plain-record + // branch of `resolveArtifactGrantBinding`, so this reads the carrier's own + // record or it iterates nothing at all — it never substitutes a stand-in. + if (isPlainRecord(grants)) { + for (const id of binding.registered) { + // The VALUE as the carrier wrote it: `{}` registers a + // deny-everything bag, a populated entry registers exactly the + // consented surface. + enforcer.registerGrantedPermissions(id, grants[id] as GrantedPermissions); + } } if (binding.unbound.length > 0) {