diff --git a/.changeset/environment-artifact-granted-permissions.md b/.changeset/environment-artifact-granted-permissions.md new file mode 100644 index 0000000000..8ce73fbf55 --- /dev/null +++ b/.changeset/environment-artifact-granted-permissions.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare `grantedPermissions` on `EnvironmentArtifactSchema` — the install-time granted permission set per plugin, keyed by manifest `id` (#14865) + +The environment artifact envelope (`@objectstack/spec/system`, re-exported from +`@objectstack/spec/cloud`) gains one optional top-level key: + +```ts +grantedPermissions?: Record // keyed by the plugin manifest `id` +``` + +This is the artifact-contract half of #11333 option A / the #13457 batch ruling: +the consented four-class permission set `{ services, hooks, network, fs }` rides +the plugin artifact contract. The **producer** is the cloud control plane's +consent-compile step (it already persists the set on +`sys_package_installation.granted_permissions`; it now has a declared place to +emit it on the envelope). The **consumer** is the materialize-time loader, which +hands each entry to `PluginPermissionEnforcer.registerGrantedPermissions`, so a +third-party plugin runs under exactly the surface the installer consented to — +independent of what its manifest requested. + +Why the spec half lands first: `EnvironmentArtifactSchema` is a plain `z.object`, +so a key the control plane writes before it is declared is silently stripped at +the runtime's artifact door. Declaring it is what makes the value reach the +loader at all. + +Contract points, each pinned by a parse test next to the schema: + +- **Absent ≠ `{}`.** Absent = no consent record (first-party / pure-metadata + package). `{}` = consent-bearing and consented to nothing. There is no + `.default({})`; both round-trip as written. +- **Key = the plugin manifest `id`**, not the control-plane `package_id` — the + identity the enforcer is queried with. Documented residual risk: a package + whose manifest `id` differs from its `package_id` must still be keyed by the + manifest `id`; the schema cannot tell the two spellings apart. +- **Value shape = `PluginPermissionsSchema`** (`.strict()`), the same declaration + the manifest's requested set uses — an unknown permission class is refused at + the artifact door, not granted silently. +- An unknown top-level sibling key is still stripped (the door did not go + passthrough). + +Additive and optional: every artifact that parsed before parses identically. +`ENVIRONMENT_ARTIFACT_SCHEMA_VERSION` stays `0.1` (it is bumped on breaking +envelope changes only). No runtime behaviour changes in this package — the +consumer wiring is #13457, behind cloud #14034. diff --git a/content/docs/concepts/north-star.mdx b/content/docs/concepts/north-star.mdx index af02971c6f..5b24bdb330 100644 --- a/content/docs/concepts/north-star.mdx +++ b/content/docs/concepts/north-star.mdx @@ -73,6 +73,10 @@ The artifact contains: serialization - `metadata` — the compiled `ObjectStackDefinition` itself - optional `builtAt` / `builtWith` provenance +- optional `grantedPermissions` — the install-time GRANTED permission set per + plugin, keyed by the plugin manifest `id`; consent state the control plane + re-emits on each assembly (absent = no consent record, `{}` = consented to + nothing), sitting beside `metadata` and outside the `checksum` digest The artifact is enough to describe what the runtime should load. It is not enough to deploy by itself; the host still supplies deployment config. diff --git a/content/docs/references/system/environment-artifact.mdx b/content/docs/references/system/environment-artifact.mdx index 0afe0ec305..1487c75371 100644 --- a/content/docs/references/system/environment-artifact.mdx +++ b/content/docs/references/system/environment-artifact.mdx @@ -31,6 +31,13 @@ identity (`commitId`, `checksum`). `checksum`. - **Deployment Config (NOT in this schema):** business DB coordinates, credentials, environment identity, secrets. Injected at runtime. +- **Consent state (this schema, `grantedPermissions`, #14865):** the + install-time GRANTED permission set per plugin, keyed by the plugin + manifest `id` — written by the control plane at consent-compile time, + read by the loader at materialize time. Control-plane state re-emitted + on every artifact assembly, not compiled metadata: it sits beside + `metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the + key's docblock. See `content/docs/concepts/north-star.mdx` §6.3 for the runtime-inputs boundary. diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 8d138e453c..d8aaff1b49 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -24,6 +24,14 @@ import { NavigationContributionSchema } from '../ui/app.zod'; * the persisted `granted_permissions` set enforced at load by the * PluginPermissionEnforcer. * + * The consented set reaches the runtime on the environment artifact + * envelope — `EnvironmentArtifactSchema.grantedPermissions` + * (`system/environment-artifact.zod.ts`, #14865): a map keyed by this + * manifest's `id` whose values are this very schema. The loader reads it + * from the environment-local carrier at materialize time, never from + * `sys_package_installation` directly (ADR-0003 / cloud ADR-0007). Absent + * there = no consent record; `{}` = consented to nothing. + * * @example * ```jsonc * { "services": ["object", "http"], "hooks": ["record.beforeInsert"], diff --git a/packages/spec/src/system/environment-artifact.test.ts b/packages/spec/src/system/environment-artifact.test.ts index 635d29e1a5..bac79afd93 100644 --- a/packages/spec/src/system/environment-artifact.test.ts +++ b/packages/spec/src/system/environment-artifact.test.ts @@ -6,6 +6,7 @@ import { EnvironmentArtifactSchema, Sha256DigestSchema, } from './environment-artifact.zod'; +import { PluginPermissionsSchema } from '../kernel/manifest.zod'; import { EXPORT_ENTRY_POINTS, @@ -246,3 +247,148 @@ describe('EnvironmentArtifactSchema (wire shape)', () => { }); }); }); + +// ─── grantedPermissions (#14865) ──────────────────────────────────────────── +// +// The artifact-contract half of #11333 option A / the #13457 batch ruling: the +// consented four-class permission set `{ services, hooks, network, fs }` rides +// the envelope — written by the cloud control plane at consent-compile time, +// read by the loader at materialize time. Before this key was declared, +// `EnvironmentArtifactSchema` (a plain `z.object`) STRIPPED it at the artifact +// door with no error — so the first pin is "the declared key survives", and its +// positive control is "an unknown sibling is still stripped": the door must not +// have gone passthrough to admit this key. Every pin here is a schema-reachable +// parse, not a type-level assertion. + +describe('grantedPermissions — install-time granted set per plugin manifest `id` (#14865)', () => { + const granted = { + '@acme/plugin-crm': { + services: ['object', 'http'], + hooks: ['record.beforeInsert'], + network: ['api.acme.com'], + fs: [], + }, + '@acme/plugin-reports': {}, + }; + + it('the declared key survives parse and round-trips the exact map', () => { + const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: granted }); + expect(parsed).toHaveProperty('grantedPermissions'); + expect(parsed.grantedPermissions).toEqual(granted); + expect(Object.keys(parsed.grantedPermissions ?? {})).toEqual(['@acme/plugin-crm', '@acme/plugin-reports']); + }); + + it('pure control: with no grantedPermissions present at all, an unknown top-level sibling is stripped (green before and after this key; red only if the door goes passthrough)', () => { + const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, notAnEnvelopeKey: { anything: 1 } }); + expect(parsed).not.toHaveProperty('notAnEnvelopeKey'); + expect(Object.keys(parsed).sort()).toEqual(['checksum', 'commitId', 'environmentId', 'metadata', 'schemaVersion']); + }); + + it('positive control: an unknown top-level sibling is STILL stripped — the door admits the declared key, not everything', () => { + const parsed = EnvironmentArtifactSchema.parse({ + ...wireMinimal, + grantedPermissions: granted, + grantedPermissionz: granted, // near-miss spelling + notAnEnvelopeKey: { anything: 1 }, + }); + expect(parsed.grantedPermissions).toEqual(granted); + expect(parsed).not.toHaveProperty('grantedPermissionz'); + expect(parsed).not.toHaveProperty('notAnEnvelopeKey'); + expect(Object.keys(parsed).sort()).toEqual( + ['checksum', 'commitId', 'environmentId', 'grantedPermissions', 'metadata', 'schemaVersion'], + ); + }); + + describe('absent ≠ `{}` — never collapsed (there is no `.default({})`, and there must not be)', () => { + it('absent stays absent: no consent record', () => { + const parsed = EnvironmentArtifactSchema.parse(wireMinimal); + expect(Object.keys(parsed)).not.toContain('grantedPermissions'); + expect(parsed.grantedPermissions).toBeUndefined(); + }); + + it('`{}` stays `{}`: consent-bearing, consented to nothing', () => { + const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: {} }); + expect(Object.keys(parsed)).toContain('grantedPermissions'); + expect(parsed.grantedPermissions).toEqual({}); + }); + + it('a per-plugin `{}` entry stays `{}`: that plugin consented to nothing', () => { + const parsed = EnvironmentArtifactSchema.parse({ + ...wireMinimal, + grantedPermissions: { '@acme/plugin-reports': {} }, + }); + expect(parsed.grantedPermissions).toEqual({ '@acme/plugin-reports': {} }); + }); + + it('the two readings stay distinguishable on the PARSED value, not only on the input', () => { + const absent = EnvironmentArtifactSchema.parse(wireMinimal); + const empty = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: {} }); + expect('grantedPermissions' in absent).toBe(false); + expect('grantedPermissions' in empty).toBe(true); + }); + }); + + describe('value shape = the strict PluginPermissionsSchema (kernel/manifest.zod.ts)', () => { + it('is the SAME declaration as the manifest requested set — identity, not a lookalike', () => { + const record = EnvironmentArtifactSchema.shape.grantedPermissions.unwrap(); + expect(record.valueType).toBe(PluginPermissionsSchema); + }); + + it('refuses an unknown permission CLASS with `unrecognized_keys` at the plugin path, rather than granting it silently', () => { + const result = EnvironmentArtifactSchema.safeParse({ + ...wireMinimal, + grantedPermissions: { '@acme/plugin-crm': { services: ['object'], shell: ['*'] } }, + }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['grantedPermissions', '@acme/plugin-crm']); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['shell']); + }); + + it('refuses a non-object per-plugin value and a non-record map', () => { + for (const bad of [ + { '@acme/plugin-crm': ['object'] }, + { '@acme/plugin-crm': 'object' }, + { '@acme/plugin-crm': null }, + ['object'], + 'object', + ]) { + expect( + EnvironmentArtifactSchema.safeParse({ ...wireMinimal, grantedPermissions: bad }).success, + `must refuse ${JSON.stringify(bad)}`, + ).toBe(false); + } + }); + + it('accepts all four consented classes exactly as the manifest declaration spells them', () => { + const full = { services: ['object'], hooks: ['record.beforeInsert'], network: ['api.acme.com'], fs: ['/tmp'] }; + const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: { '@acme/plugin-crm': full } }); + expect(parsed.grantedPermissions?.['@acme/plugin-crm']).toEqual(full); + }); + }); + + describe('key = the plugin manifest `id` — the documented assumption, with the residual risk pinned on the contract text', () => { + // The schema cannot distinguish a manifest `id` from a control-plane + // `package_id` (both are strings), so this is a pin on the CONTRACT TEXT a + // producer reads: the key's own description must name the manifest `id` as + // the key, name `package_id` as what it is not, and state absent vs `{}`. + // If the description stops saying so, the assumption is no longer + // documented on the surface that carries it. + it('the key description names the manifest `id` as the key, rules out `package_id`, and states absent vs `{}`', () => { + const description = EnvironmentArtifactSchema.shape.grantedPermissions.description ?? ''; + expect(description).toMatch(/keyed by the plugin manifest `id`/); + expect(description).toMatch(/not the control-plane `package_id`/); + expect(description).toMatch(/Absent = no consent record/); + expect(description).toMatch(/`\{\}` = consent-bearing and consented to nothing/); + }); + + it('any string key parses — which IS the residual risk: a `package_id`-shaped key is accepted and would simply never be looked up', () => { + const parsed = EnvironmentArtifactSchema.parse({ + ...wireMinimal, + grantedPermissions: { pkg_01HABCDE: { services: ['object'] } }, + }); + expect(parsed.grantedPermissions).toEqual({ pkg_01HABCDE: { services: ['object'] } }); + }); + }); +}); diff --git a/packages/spec/src/system/environment-artifact.zod.ts b/packages/spec/src/system/environment-artifact.zod.ts index 6ae63da098..09ce094cf1 100644 --- a/packages/spec/src/system/environment-artifact.zod.ts +++ b/packages/spec/src/system/environment-artifact.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { ObjectStackDefinitionSchema } from '../stack.zod'; +import { PluginPermissionsSchema } from '../kernel/manifest.zod'; /** * # Environment Artifact Envelope @@ -32,6 +33,13 @@ import { ObjectStackDefinitionSchema } from '../stack.zod'; * `checksum`. * - **Deployment Config (NOT in this schema):** business DB coordinates, * credentials, environment identity, secrets. Injected at runtime. + * - **Consent state (this schema, `grantedPermissions`, #14865):** the + * install-time GRANTED permission set per plugin, keyed by the plugin + * manifest `id` — written by the control plane at consent-compile time, + * read by the loader at materialize time. Control-plane state re-emitted + * on every artifact assembly, not compiled metadata: it sits beside + * `metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the + * key's docblock. * * See {@link content/docs/concepts/north-star.mdx} §6.3 for the * runtime-inputs boundary. @@ -116,6 +124,61 @@ export const EnvironmentArtifactSchema = lazySchema(() => z.object({ */ metadata: ObjectStackDefinitionSchema, + /** + * Install-time GRANTED permission set, per plugin, keyed by the plugin + * manifest `id` (#14865 — the artifact-contract half of #11333 option A + * and the #13457 batch ruling; ADR-0025 §3.5 step 2). + * + * - **Producer:** the cloud control plane's consent-compile step. The + * install-consent flow persists the consented four-class set + * `{ services, hooks, network, fs }` on + * `sys_package_installation.granted_permissions`; when the control plane + * assembles this envelope it copies that set here, one entry per + * consent-bearing package, so the environment-local carrier ships it and + * no runtime path ever reads `sys_package_installation` (ADR-0003 / + * cloud ADR-0007). + * - **Consumer:** the materialize-time loader, which hands each entry to + * `PluginPermissionEnforcer.registerGrantedPermissions(pluginName, granted)` + * (`packages/core/src/security/plugin-permission-enforcer.ts`) so a + * third-party plugin runs under exactly the surface the installer + * consented to — independent of what its manifest *requested* + * (`ManifestSchema.permissions`). + * + * **Absent ≠ `{}`.** The key ABSENT means no consent record exists for + * this environment (first-party / pure-metadata packages; an artifact + * assembled before consent existed). An EMPTY map `{}` — or a per-plugin + * entry `{}` — is a consent record that consented to NOTHING. Cloud writes + * that distinction and the loader decides on it, so this key carries no + * `.default({})` and never may: `{}` round-trips as `{}`, absence + * round-trips as absence (pinned next to this file). + * + * **Key = the plugin manifest `id`**, NOT the control-plane `package_id` — + * the manifest `id` is the identity the enforcer is queried with + * (`AppPlugin` derives the kernel plugin name from `bundle.manifest.id`); + * keying by `package_id` would need a second name→package resolution path. + * Documented assumption, and the residual risk this contract accepts: a + * package whose manifest `id` differs from its `package_id` is keyed by + * the manifest `id` here, and a producer that keys such an entry by + * `package_id` instead writes an entry the enforcer is never queried for — + * the consented set then silently fails to bind to that plugin. This + * schema cannot tell the two spellings apart; the producer owns it. + * + * Value shape is `PluginPermissionsSchema` (`kernel/manifest.zod.ts`, + * `.strict()`) — the same declaration the manifest's *requested* set uses, + * so granted ⊆ requested is expressible key-for-key, and an unknown + * permission class is refused at the artifact door rather than granted + * silently. Sits beside `metadata`, outside the `checksum` digest (which + * covers the `metadata` block only). + */ + grantedPermissions: z.record(z.string(), PluginPermissionsSchema).optional() + .describe( + 'Install-time GRANTED permission set per plugin, keyed by the plugin manifest `id` ' + + '(not the control-plane `package_id`). Written by the cloud control plane at consent-compile ' + + 'time from `sys_package_installation.granted_permissions`; consumed by the materialize-time ' + + 'loader via `PluginPermissionEnforcer.registerGrantedPermissions`. Absent = no consent record; ' + + '`{}` = consent-bearing and consented to nothing — the two are never collapsed.', + ), + // ── Retired v0 keys (#4740, ADR-0049) ────────────────────────────── // Declared-but-never-implemented in the pre-convergence ./system shape. // Tombstoned (not silently stripped) so a producer that authors one gets