Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/environment-artifact-granted-permissions.md
Original file line number Diff line number Diff line change
@@ -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<string, PluginPermissions> // 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.
4 changes: 4 additions & 0 deletions content/docs/concepts/north-star.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions content/docs/references/system/environment-artifact.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/kernel/manifest.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
146 changes: 146 additions & 0 deletions packages/spec/src/system/environment-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EnvironmentArtifactSchema,
Sha256DigestSchema,
} from './environment-artifact.zod';
import { PluginPermissionsSchema } from '../kernel/manifest.zod';

import {
EXPORT_ENTRY_POINTS,
Expand Down Expand Up @@ -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'] } });
});
});
});
63 changes: 63 additions & 0 deletions packages/spec/src/system/environment-artifact.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading