Skip to content

Commit ea4d164

Browse files
claude[bot]os-samclaude
authored
feat(runtime): bind an artifact's install-time granted permissions to its plugins at load (#17137)
* feat(runtime): bind the artifact's install-time granted permissions per plugin at load 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 <noreply@anthropic.com> * chore(changeset): declare the artifact granted-permissions load binding Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(runtime): carry `grantedPermissions` across the artifact envelope unwrap 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: os-sam <sam@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5505646 commit ea4d164

9 files changed

Lines changed: 793 additions & 4 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes.
6+
7+
`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).
8+
9+
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.
10+
11+
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.
12+
13+
New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`.
14+
15+
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.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #13457 — `AppPlugin.init()` is the ONE production caller of the artifact→
4+
// enforcer seam, and this file pins that it is: the wiring, not the seam's own
5+
// logic (that is `security/artifact-granted-permissions.test.ts`).
6+
//
7+
// The site matters as much as the behaviour. `AppPlugin` is the single point
8+
// where an environment artifact becomes a kernel plugin on BOTH paths — the
9+
// self-hosted `createStandaloneStack` and the cloud control plane's
10+
// `ArtifactKernelFactory`, which constructs the same object — so a consent
11+
// record reaches the enforcer without either caller changing a line. Before
12+
// this, `PluginPermissionEnforcer` had zero production callers (#7500,
13+
// re-measured on this branch).
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import { AppPlugin } from './app-plugin.js';
17+
import type { PluginContext } from '@objectstack/core';
18+
19+
const bootCtx = () => {
20+
const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() };
21+
return {
22+
logger,
23+
registerService: vi.fn(),
24+
registerServiceFactory: vi.fn(),
25+
replaceService: vi.fn(),
26+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
27+
getServices: vi.fn(() => new Map()),
28+
getServiceScoped: vi.fn(),
29+
hook: vi.fn(),
30+
trigger: vi.fn(),
31+
getKernel: vi.fn(),
32+
} as unknown as PluginContext & { logger: typeof logger };
33+
};
34+
35+
const bundle = (extra: Record<string, unknown> = {}) => ({
36+
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' },
37+
packages: [
38+
{ manifest: { id: 'com.acme.crm', name: 'crm', version: '1.0.0', type: 'app' } },
39+
{ manifest: { id: 'com.acme.reports', name: 'reports', version: '1.0.0', type: 'module' } },
40+
],
41+
...extra,
42+
});
43+
44+
describe('#13457 — AppPlugin.init binds the artifact\'s granted permissions', () => {
45+
it('an artifact with NO grantedPermissions key allocates no enforcer at all', async () => {
46+
const ctx = bootCtx();
47+
const plugin = new AppPlugin(bundle());
48+
await plugin.init(ctx);
49+
50+
// ⭐ Not "an enforcer that denies nothing" — no enforcer. This is every
51+
// artifact that ships today, and the boot has to be byte-for-byte what
52+
// it was: absent is no consent record, never a deny.
53+
expect(plugin.permissionEnforcer).toBeUndefined();
54+
expect(plugin.grantBinding).toBeUndefined();
55+
});
56+
57+
it('a consent-bearing artifact registers each entry under its manifest id', async () => {
58+
const ctx = bootCtx();
59+
const plugin = new AppPlugin(bundle({
60+
grantedPermissions: { 'com.acme.crm': { services: ['object'], hooks: [] } },
61+
}));
62+
await plugin.init(ctx);
63+
64+
const e = plugin.permissionEnforcer;
65+
expect(e).toBeDefined();
66+
// Keyed by the plugin manifest `id` — ⛔ never the kernel plugin name
67+
// (`plugin.app.com.acme.crm`), which is what `AppPlugin` registers
68+
// ITSELF under and is not the key the artifact contract writes.
69+
expect(plugin.name).toBe('plugin.app.com.acme.crm');
70+
expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('object')).toBe(true);
71+
expect(e!.getPluginPermissions('com.acme.crm')!.canAccessService('storage')).toBe(false);
72+
// The sibling package carries no consent record — nothing registered for it.
73+
expect(e!.getPluginPermissions('com.acme.reports')).toBeUndefined();
74+
expect(plugin.grantBinding).toMatchObject({
75+
declared: true,
76+
registered: ['com.acme.crm'],
77+
unregistered: ['com.acme.reports'],
78+
unbound: [],
79+
});
80+
});
81+
82+
it('binds on an EMPTY environment too, so a grant that binds to nothing is still heard', async () => {
83+
// An empty env has no app payload and `init()` returns early — but the
84+
// binding runs BEFORE that return, because an artifact carrying grants
85+
// for packages it does not ship is exactly the fault that shows up here.
86+
const ctx = bootCtx();
87+
const plugin = new AppPlugin({
88+
manifest: { plugins: [], drivers: [] },
89+
grantedPermissions: { 'com.acme.ghost': { services: ['object'] } },
90+
});
91+
await plugin.init(ctx);
92+
93+
expect(plugin.grantBinding).toMatchObject({ declared: true, registered: [], unbound: ['com.acme.ghost'] });
94+
expect(
95+
ctx.logger.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')),
96+
).toBe(true);
97+
});
98+
});

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core';
3+
import {
4+
Plugin,
5+
PluginContext,
6+
createPluginPermissionEnforcer,
7+
wireAuthoredTranslationSync,
8+
type PluginPermissionEnforcer,
9+
} from '@objectstack/core';
410
import { resolveArtifactCollections } from './artifact-collections.js';
11+
import {
12+
registerArtifactGrantedPermissions,
13+
type ArtifactGrantBinding,
14+
} from './security/artifact-granted-permissions.js';
515
import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core';
616
import { resolveTenancyPosture } from '@objectstack/types';
717
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
@@ -125,6 +135,22 @@ export class AppPlugin implements Plugin {
125135
* of arriving at teardown time.
126136
*/
127137
private initCtx?: PluginContext;
138+
/**
139+
* The enforcer holding this artifact's install-time GRANTED permission sets
140+
* (ADR-0025 F4), or `undefined` when the artifact carried no
141+
* `grantedPermissions` key — which is every artifact built before consent
142+
* existed and every `defineStack()` config, so this stays `undefined` on
143+
* every boot shape that ships today.
144+
*
145+
* Constructed in `init()` only when there is something to register, so a
146+
* boot with no consent record allocates nothing and behaves byte-for-byte
147+
* as it did. Public and readonly-by-accessor so the materialize seam that
148+
* will QUERY it — and a composition pin — can reach the registry rather
149+
* than rebuilding it from the artifact a second time.
150+
*/
151+
private grantEnforcer?: PluginPermissionEnforcer;
152+
/** What `grantedPermissions` bound to on this artifact — see {@link ArtifactGrantBinding}. */
153+
private grantBindingResult?: ArtifactGrantBinding;
128154
/** When true, init/start become no-ops — env has no app payload. */
129155
private readonly empty: boolean = false;
130156
/**
@@ -171,6 +197,29 @@ export class AppPlugin implements Plugin {
171197
return (this.resolvedCollections ??= resolveArtifactCollections(this.bundle));
172198
}
173199

200+
/**
201+
* The enforcer this artifact's consent records were registered on, or
202+
* `undefined` when the artifact declared no `grantedPermissions` key.
203+
*
204+
* ⛔ `undefined` here means "no consent record for this environment", NEVER
205+
* "denied": a caller that reads `undefined` as a deny bricks every boot
206+
* shape that ships today (clause 1.3). The three states this distinguishes
207+
* are written out on `registerArtifactGrantedPermissions`.
208+
*/
209+
get permissionEnforcer(): PluginPermissionEnforcer | undefined {
210+
return this.grantEnforcer;
211+
}
212+
213+
/**
214+
* What the artifact's `grantedPermissions` map bound to on this boot —
215+
* `undefined` when the key was absent. Public so a composition pin can read
216+
* the binding without re-deriving it, and so a caller can tell a declared
217+
* empty map (`declared: true`, nothing registered) from an absent one.
218+
*/
219+
get grantBinding(): ArtifactGrantBinding | undefined {
220+
return this.grantBindingResult;
221+
}
222+
174223
constructor(
175224
bundle: any,
176225
projectContext?: AppPluginProjectContext,
@@ -264,6 +313,16 @@ export class AppPlugin implements Plugin {
264313
// empty-env early return, so teardown is armed on every path init
265314
// takes.
266315
this.initCtx = ctx;
316+
// Bind the install-time GRANTED permission set (ADR-0025 F4, #13457)
317+
// BEFORE anything this plugin registers on the kernel. This is the
318+
// materialize-time moment the artifact contract names as the consumer
319+
// of `EnvironmentArtifactSchema.grantedPermissions`, and it runs ahead
320+
// of the empty-env return on purpose: a consent record that binds to
321+
// nothing has to be heard on an empty environment too, which is exactly
322+
// where an artifact carrying grants for packages it does not ship shows
323+
// up. A no-op — not even an allocation — on every artifact that carries
324+
// no `grantedPermissions` key.
325+
this.bindGrantedPermissions(ctx);
267326
// Install the engine-wide default hook body runner FIRST — even for
268327
// empty envs (an empty env is exactly where a user will author their
269328
// first Studio hook). Runs in init (Phase 1) so it is in place before
@@ -326,6 +385,38 @@ export class AppPlugin implements Plugin {
326385
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
327386
}
328387

388+
/**
389+
* Register the install-time GRANTED permission set this artifact carries,
390+
* one entry per consent-bearing package, on an enforcer this plugin owns
391+
* (ADR-0025 F4 / #13457 — the consumer half of
392+
* `EnvironmentArtifactSchema.grantedPermissions`).
393+
*
394+
* The whole method is behind the `=== undefined` gate below, and that gate
395+
* is the clause-1.3 guarantee in code: an artifact with no consent record
396+
* takes no branch, allocates no enforcer and registers nothing, so ABSENT
397+
* can never become "denied". `{}` is not absent and does not take the early
398+
* return — a declared-but-empty map is a consent record that names no
399+
* package, which is a different reading and is recorded as one.
400+
*
401+
* ⛔ Never `??`/`||` on `grantedPermissions`: both spellings turn a declared
402+
* `{}` into absence and erase a distinction the producer pins both ways.
403+
*/
404+
private bindGrantedPermissions(ctx: PluginContext): void {
405+
if ((this.bundle as { grantedPermissions?: unknown } | null | undefined)?.grantedPermissions === undefined) {
406+
return;
407+
}
408+
const enforcer = createPluginPermissionEnforcer(ctx.logger);
409+
const binding = registerArtifactGrantedPermissions(this.bundle, enforcer, { logger: ctx.logger });
410+
this.grantEnforcer = enforcer;
411+
this.grantBindingResult = binding;
412+
ctx.logger.info('[AppPlugin] registered install-time granted permissions', {
413+
pluginName: this.name,
414+
registered: [...binding.registered],
415+
unregistered: [...binding.unregistered],
416+
unbound: [...binding.unbound],
417+
});
418+
}
419+
329420
/**
330421
* Seed persisted package disable-state into the registry's initial-disabled
331422
* set, so every later registration path — boot artifact decomposition,

‎packages/runtime/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ export {
140140
type RateLimitKeyKind,
141141
type RateLimitLogger,
142142
type ActorUser,
143+
carriedPackageIds,
144+
resolveArtifactGrantBinding,
145+
registerArtifactGrantedPermissions,
146+
type ArtifactGrantBinding,
143147
} from './security/index.js';
144148

145149
// ── Observability primitives ──────────────────────────────────────────
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #13457 — the `{ schemaVersion, metadata }` unwrap must carry
4+
// `grantedPermissions` across.
5+
//
6+
// `EnvironmentArtifactSchema` puts the install-time consented set BESIDE
7+
// `metadata`, and the unwrap hands the kernel `metadata` alone — so every key
8+
// standing beside it is dropped. For `grantedPermissions` that loss is SILENT
9+
// and indistinguishable from the legitimate reading: an absent key means "no
10+
// consent record", so an envelope stripped of its consent records boots green,
11+
// enforcing nothing, with nothing to see. That is why this is pinned on the
12+
// loader rather than left to the consumer.
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { mkdtempSync, writeFileSync } from 'node:fs';
16+
import { tmpdir } from 'node:os';
17+
import { join } from 'node:path';
18+
import { loadArtifactBundle } from './load-artifact-bundle.js';
19+
20+
const write = (body: unknown): string => {
21+
const dir = mkdtempSync(join(tmpdir(), 'os-13457-'));
22+
const file = join(dir, 'objectstack.json');
23+
writeFileSync(file, JSON.stringify(body), 'utf-8');
24+
return file;
25+
};
26+
27+
const envelope = (extra: Record<string, unknown>) => ({
28+
schemaVersion: '0.1',
29+
environmentId: 'env_1',
30+
commitId: 'c1',
31+
checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
32+
metadata: { manifest: { id: 'com.acme.crm', name: 'crm', version: '1.0.0', type: 'app' } },
33+
...extra,
34+
});
35+
36+
describe('#13457 — the envelope unwrap carries the consented set', () => {
37+
it('a populated `grantedPermissions` survives the unwrap', async () => {
38+
const grants = { 'com.acme.crm': { services: ['object'], hooks: [], network: [], fs: [] } };
39+
const bundle = await loadArtifactBundle(write(envelope({ grantedPermissions: grants })), {
40+
unwrapEnvelope: true,
41+
});
42+
// The unwrapped bundle is `metadata`; the consented set rode across.
43+
expect(bundle.manifest.id).toBe('com.acme.crm');
44+
expect(bundle.grantedPermissions).toEqual(grants);
45+
});
46+
47+
it('a declared EMPTY map survives as `{}` — not as absence', async () => {
48+
const bundle = await loadArtifactBundle(write(envelope({ grantedPermissions: {} })), {
49+
unwrapEnvelope: true,
50+
});
51+
// ⭐ `{}` is a consent record that names no package. A truthiness or
52+
// emptiness test in the carry would drop it and turn it into the absent
53+
// reading, which is the collapse the producer pins against.
54+
expect(bundle.grantedPermissions).toEqual({});
55+
expect('grantedPermissions' in bundle).toBe(true);
56+
});
57+
58+
it('an envelope with NO `grantedPermissions` does not grow one', async () => {
59+
const bundle = await loadArtifactBundle(write(envelope({})), { unwrapEnvelope: true });
60+
expect('grantedPermissions' in bundle).toBe(false);
61+
});
62+
63+
it('an UNWRAPPED artifact is untouched — the carry is the unwrap\'s business only', async () => {
64+
// No `schemaVersion`, so nothing unwraps and the parsed object is the
65+
// bundle; the key (present or not) is already where the consumer reads.
66+
const flat = { manifest: { id: 'com.acme.crm' }, grantedPermissions: { 'com.acme.crm': {} } };
67+
const bundle = await loadArtifactBundle(write(flat), { unwrapEnvelope: true });
68+
expect(bundle.grantedPermissions).toEqual({ 'com.acme.crm': {} });
69+
});
70+
});

‎packages/runtime/src/load-artifact-bundle.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,29 @@ export async function loadArtifactBundle(
8585
try {
8686
const raw = await readArtifactSource(absArtifactPath, { fetchTimeoutMs: opts.fetchTimeoutMs });
8787
const parsed = JSON.parse(raw);
88-
bundle = opts.unwrapEnvelope && parsed?.schemaVersion != null && parsed?.metadata !== undefined
89-
? parsed.metadata
90-
: parsed;
88+
const unwrapping = Boolean(opts.unwrapEnvelope)
89+
&& parsed?.schemaVersion != null && parsed?.metadata !== undefined;
90+
bundle = unwrapping ? parsed.metadata : parsed;
91+
// [#13457] The unwrap keeps `metadata` and DROPS every key standing
92+
// beside it, so an envelope key whose declared consumer is this loader
93+
// has to be carried across by name or it is silently gone.
94+
// `grantedPermissions` is that key: `EnvironmentArtifactSchema` puts the
95+
// install-time consented set BESIDE `metadata` (outside the checksum
96+
// digest), and names the materialize-time loader as its consumer — so
97+
// before this line an envelope artifact reached the kernel with every
98+
// consent record stripped, and the enforcer had nothing to register.
99+
// (⛔ Not "the gate had nothing to enforce": there is no gate — this
100+
// round fills the registry, and nothing on this tree queries it yet.)
101+
// Nothing threw: the key was simply absent, which is also a legitimate
102+
// reading ("no consent record"), which is why it could be lost in
103+
// silence.
104+
// ⛔ `!== undefined`, never a truthiness or emptiness test: `{}` is a
105+
// consent record that consented to nothing and must survive the unwrap
106+
// as `{}`, while a genuinely absent key must NOT be created here.
107+
if (unwrapping && parsed.grantedPermissions !== undefined
108+
&& bundle !== null && typeof bundle === 'object') {
109+
bundle.grantedPermissions = parsed.grantedPermissions;
110+
}
91111
} catch (err: any) {
92112
// An ABSENT artifact is not a failure (#4085). The platform is a
93113
// development platform first: `os serve objectstack.config.ts` boots

0 commit comments

Comments
 (0)