Skip to content
15 changes: 15 additions & 0 deletions .changeset/artifact-granted-permissions-load-binding.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions packages/runtime/src/app-plugin.granted-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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);
});
});
93 changes: 92 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export {
type RateLimitKeyKind,
type RateLimitLogger,
type ActorUser,
carriedPackageIds,
resolveArtifactGrantBinding,
registerArtifactGrantedPermissions,
type ArtifactGrantBinding,
} from './security/index.js';

// ── Observability primitives ──────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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': {} });
});
});
26 changes: 23 additions & 3 deletions packages/runtime/src/load-artifact-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading