diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md new file mode 100644 index 0000000000..e39281c242 --- /dev/null +++ b/.changeset/artifact-granted-permissions-load-binding.md @@ -0,0 +1,15 @@ +--- +'@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. + +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/app-plugin.granted-permissions.test.ts b/packages/runtime/src/app-plugin.granted-permissions.test.ts new file mode 100644 index 0000000000..29f206cb81 --- /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 — nothing registered for it. + expect(e!.getPluginPermissions('com.acme.reports')).toBeUndefined(); + expect(plugin.grantBinding).toMatchObject({ + declared: true, + registered: ['com.acme.crm'], + unregistered: ['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, 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 71123c8a6e..e80e8d5d82 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 registered) 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, + registered: [...binding.registered], + unregistered: [...binding.unregistered], + 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/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..d5f0c6e8e7 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -85,9 +85,29 @@ 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 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. + 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 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..16ee8322f7 --- /dev/null +++ b/packages/runtime/src/security/artifact-granted-permissions.test.ts @@ -0,0 +1,257 @@ +// 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 + // register 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.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 + // 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.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', () => { + const e = enforcer(); + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.crm': {} } }), + e, + ); + + 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 + // 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 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 + // does today. + const e = enforcer(); + const binding = registerArtifactGrantedPermissions( + artifact({ grantedPermissions: { 'com.acme.crm': CONSENTED } }), + e, + ); + + expect(binding.registered).toEqual(['com.acme.crm']); + expect(binding.unregistered).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.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')), + ).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.registered).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 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'); + }); + + // ⭐ 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 new file mode 100644 index 0000000000..bf2cf9801c --- /dev/null +++ b/packages/runtime/src/security/artifact-granted-permissions.ts @@ -0,0 +1,222 @@ +// 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, nothing on the enforcer. + * + * ⛔ 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 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 — 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 + * 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: [], registered: [], unregistered: [], 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: [], registered: [], unregistered: [], unbound: [] }; + } + const carried = carriedPackageIds(artifact); + 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)) registered.push(key); + else unbound.push(key); + } + return { + declared: true, + carried, + registered, + unregistered: carried.filter((id) => !registered.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?: 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) { + // 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';