diff --git a/.drive/deferred.md b/.drive/deferred.md index 9947c3ed..1d733aaa 100644 --- a/.drive/deferred.md +++ b/.drive/deferred.md @@ -120,3 +120,19 @@ deploy target, lowering unchanged. - **Factory name** — `pnPostgres` is a placeholder; Prisma Next → Prisma Data rename incoming at GA. Rename the factory when the product name lands. + +# Deploy log redaction — deferred (2026-07-16) + +- **Redact known secret values in deploy log output.** Separate from + [ADR-0032](../docs/design/90-decisions/ADR-0032-the-deploy-reports-what-each-node-became.md), + which makes the *node report* safe by construction (an allowlist projected by + each descriptor). This is the complementary net for the strings we do NOT + author: Alchemy progress lines, errors that echo a DSN, stack traces. Match + known secret values in stdout/stderr and mask them, as GitHub Actions does for + registered secrets. Known limits to design around, and why it can't be the + primary control: some credentials are minted mid-run (`s3Credentials` mints a + SigV4 pair), so the redactor must be updated as they appear or anything printed + first is unmasked; and values are transformed before printing (a password in a + DSN is percent-encoded, JSON escapes it again), so literal matching misses + encoded forms. Defense-in-depth only — never the thing a feature relies on to + be safe. Origin: operator design discussion 2026-07-16 (ADR-0032 shaping). diff --git a/docs/design/90-decisions/ADR-0032-the-deploy-reports-what-each-node-became.md b/docs/design/90-decisions/ADR-0032-the-deploy-reports-what-each-node-became.md new file mode 100644 index 00000000..a1798472 --- /dev/null +++ b/docs/design/90-decisions/ADR-0032-the-deploy-reports-what-each-node-became.md @@ -0,0 +1,147 @@ +# ADR-0032: The deploy reports what each node became, projected by its descriptor + +## Decision + +`prisma-composer deploy` reports what it did: a map from each graph node to a +small description of the platform entity that node became. It is keyed by the +node's graph id, which is already its deployment address — the same id the +config keys and bundle ids ride. + +The split is: **core stamps the identity it already owns from the graph** — +`kind`, `extension`, `name` — and the **descriptor that owns the entity** adds +the facts only it knows: the platform id, and an address when that address is +public. A `LoweredNode`, what every lowering phase already returns, gains an +optional `report` beside its `outputs`; the descriptor builds it where it has +the resource in hand. Core never reaches into `outputs` and decides for itself +what to publish. A node that reports nothing is simply absent; a node with +nothing to add reports `{}` and is listed by its identity alone. + +The map is the stack's outputs, which Alchemy already prints — so a deploy that +today ends in `{ outputs: {} }` says what it did instead. + +A compute service reports its public address; a database reports no connection +information at all: + +```jsonc +{ + "site": { + "kind": "compute", + "extension": "@prisma/composer-prisma-cloud", + "name": "site", + "id": "cps_m2u4w6w93v8m1hkc8bdl4wzz", + "url": "https://m2u4w6w93v8m1hkc8bdl4wzz.ewr.prisma.build" + }, + "catalog.database": { + "kind": "prisma-next", + "extension": "@prisma/composer-prisma-cloud", + "name": "database", + "id": "db_cmdye4tfpe2xiv84v75tqfsz" + // no url: this node's `url` is its connection string, a credential + } +} +``` + +The rule a descriptor follows is **include, never exclude**: an entry carries +the fields that were deliberately put in it. A field a descriptor does not name +is absent, so a new field is never published by having been forgotten about. + +## Reasoning + +**The map already exists; we throw it away.** `lowering()` (core's `deploy.ts`) +walks the graph holding `lowered` — a Map from node id to that node's +`descriptor.deploy(...)` result — and then ends with a hardcoded +`return { outputs: {} }`. The print path exists too: the generated stack's +default export *is* `lower(app, config, opts)`, and Alchemy prints a stack's +outputs. The `{ outputs: {} }` at the end of every deploy log is that mechanism +working correctly with nothing in it. + +**The interesting value is already computed.** The Prisma Cloud compute +descriptor already returns `outputs: { url: deployment.deployedUrl, projectId }`. +The deployed URL — the single thing a person or a CI job most wants after a +deploy — is derived, used for wiring, and then discarded. + +**Recovering it afterwards is archaeology, and it is ambiguous.** Today the only +way to answer "where did that go?" is to query the Management API and match on +names (`website/scripts/verify-deployed.ts` does exactly this). That needs +credentials and an SDK dependency, and it cannot always answer: the API reports +`branchId: null` for every compute service, so a project holding a production +`site` and a staged `site` offers no way to tell which is which. The deploy never +has to ask — it is holding the id it just deployed. + +**Safety cannot be decided centrally, because field names do not carry +sensitivity.** A field called `url` is a public HTTP endpoint on a compute node +and a Postgres connection string on a `prisma-next` node. Same name, opposite +sensitivity. A rule phrased over field names would either publish a credential or +suppress the endpoint. Only the descriptor knows which it produced, and +`LoweredNode.outputs` is extension-defined and opaque to core by design — the +core stays thin and target-specific knowledge stays in the pack. + +**Nor by node kind.** "A service's outputs are public, a resource's are not" is +the obvious rule and it is also wrong: `s3-store` is a *service* whose `deploy` +spreads `accessKeyId` and `secretAccessKey` into its outputs, and +`s3-credentials` is a resource whose outputs are *only* a minted key pair — +neither of them named `url`, so a field-name rule prints both. Every heuristic +available to core is a heuristic about someone else's data. + +**An allowlist cannot leak what it never held.** Because the report is +constructed rather than filtered, a mistake in it omits a field; it does not +expose one. That matches how this codebase already treats sensitivity — carried +by the type at the source (`SecretBox`, Effect's `Redacted`) rather than scrubbed +out of text at the edge (ADR-0029). + +## Consequences + +- A deploy now says where it published. The docs site's smoke check can drop its + `@prisma/management-api-sdk` dependency, its project-name lookup, and the + guard it needs today for two indistinguishable `site` services — but only + once the report is *machine*-readable. Alchemy prints the outputs for a + person; the CLI runs it with `stdio: 'inherit'` and never sees them. A + parseable surface (`.prisma-composer/deployment.json`, or `--json`) is a + follow-on, and it needs its own decision about who writes it: the stack, from + inside the alchemy child, or the CLI, by capturing what it currently streams. +- `lower()`'s return value is published surface (`@prisma/composer/deploy`), so + this changes a public type and the stack's printed outputs. +- A new extension reports nothing until its descriptor opts in. A missing report + is a visible gap, which is the failure we want; the alternative failure is a + published credential. +- Adding a field to a report is a deliberate act by the person who owns that + entity and knows whether it is a secret. +- The report is the deploy-time counterpart to the topology artifact: the + topology is what you declared, this is what it became. + +## Alternatives considered + +- **Print the lowered map and redact secrets by string-matching the output.** + Rejected *as the control for this report*, for three reasons. Some credentials + do not exist when the deploy starts — `s3Credentials` mints a SigV4 key pair + mid-run — so a redactor only masks what has been registered by the time a line + prints, and one missed registration is a leak. Values are transformed before + they are printed: a password inside a DSN is percent-encoded, and JSON output + escapes it again, so literal matching misses every encoded form. And it inverts + the stance the codebase already takes, letting a raw secret exist as a plain + string and hoping to catch it at the edge. Redaction is still worth having as + defense-in-depth over the deploy log *at large* — progress lines, errors that + echo a DSN, stack traces — where we do not author the string. That is a + separate change; it is not what makes this report safe. +- **Decide safety centrally by field name** (for example: omit `url` on + resources, keep it on services). Rejected: `url` is public on a compute node + and a credential on a `prisma-next` node, and the S3 credentials resource's + secrets are `accessKeyId`/`secretAccessKey` — not named `url` at all — so a + name rule would print them untouched. +- **Keep querying the Management API afterwards.** The status quo. Rejected as + the answer: it needs credentials the caller may not have, and `branchId: null` + makes production and a stage indistinguishable. +- **Report the root module's outputs.** Rejected: a closed root returns nothing, + and a deployed URL is not an authoring-time value — it does not exist until the + deploy has run. + +## Related + +- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — sensitivity carried by + the type rather than by scrubbing; the projection keeps that true of the report. +- [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) — the + extension registries a descriptor is routed through. +- [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) + — stages resolve to a Project and Branch; the report removes the need to + re-derive which one a deploy hit. +- [`deploy-cli.md`](../10-domains/deploy-cli.md) — the pipeline this reports on. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index d8ce794f..ed6e048d 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -53,3 +53,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — A secret is a distinct forwardable slot (not a param): a service declares a nameless `secret()` need, the root binds it to a platform env-var via `envSecret`, and it reads back as a redacting `SecretBox`; the framework carries only the name (pointer rows + boot double-lookup + preflight). *(Proposed)* - [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the workspace deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)* - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)* +- [ADR-0032](ADR-0032-the-deploy-reports-what-each-node-became.md) — The deploy reports a map from each graph node to the entity it became (keyed by the node's address; a compute service carries its public `url`, a database carries no connection). Each entry is projected by the descriptor that owns the entity — an allowlist, because field names don't carry sensitivity (`url` is public on compute, a connection string on prisma-next) and core can't tell them apart. *(Proposed)* diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index 07aeb3c7..116ea0e0 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -939,3 +939,107 @@ describe('mergedProviders', () => { expect(mergedProviders(config)).toBeDefined(); }); }); + +// ——— ADR-0032: the stack's outputs are a report of what each node became. +// The fake extension here mirrors the real shape that makes the decision +// necessary: every node's `outputs` carry a secret (a connection string, a +// minted key), and only the fields the descriptor names in `report` are +// published. +function reportingExtension() { + const descriptor: ExtensionDescriptor = { + id: 'test/pack', + providers: () => { + throw new Error('providers() must not be called by lowering()'); + }, + application: { provision: () => Effect.succeed({ outputs: {} }) }, + nodes: { + // Mirrors postgres: `url` in outputs IS the connection string, so the + // report carries identity only. + 'fake/db': Object.assign( + (ctx: LowerContext) => + Effect.succeed({ + outputs: { url: `postgres://user:hunter2@host/${ctx.id}` }, + report: { id: `db_${ctx.id}` }, + }), + { kind: 'resource' as const }, + ), + // A node that publishes nothing — the default for an extension that has + // not opted in. + 'fake/quiet': Object.assign( + () => Effect.succeed({ outputs: { secretAccessKey: 'sk_live_quiet' } }), + { kind: 'resource' as const }, + ), + // Mirrors s3-store: a SERVICE whose outputs carry a minted secret + // alongside its public url. + 'fake/compute': { + kind: 'service' as const, + provision: () => Effect.succeed({ outputs: { serviceId: 'cps_1' } }), + serialize: () => Effect.succeed({ outputs: {} }), + package: () => Effect.succeed({ path: '/tmp/a.tar.gz', sha256: 'sha' }), + deploy: (_ctx, provisioned) => + Effect.succeed({ + outputs: { url: 'https://svc.example', secretAccessKey: 'sk_live_deploy' }, + report: { id: provisioned.outputs['serviceId'], url: 'https://svc.example' }, + }), + }, + }, + }; + return { + extensions: [descriptor], + state: () => stateSentinel('config-default'), + } satisfies PrismaAppConfig; +} + +describe('the deploy reports what each node became (ADR-0032)', () => { + const dbNode = () => + resource({ + name: 'database', + extension: 'test/pack', + provides: providerContract('fake/db', { url: '' }), + }); + const quietNode = () => + resource({ + name: 'quiet', + extension: 'test/pack', + provides: providerContract('fake/quiet', {}), + }); + + const root = () => + module('shop', {}, (h) => { + const db = h.provision(dbNode(), { id: 'db' }); + h.provision(quietNode(), { id: 'quiet' }); + h.provision(app('fake/compute', { db: dbEnd() }), { id: 'svc', deps: { db } }); + return {}; + }); + + test('keys every reported node by its address, and stamps the identity core owns', () => { + const result = run(lowering(root(), reportingExtension(), opts(svcBundles))); + + expect(result.outputs).toEqual({ + db: { kind: 'fake/db', extension: 'test/pack', name: 'database', id: 'db_db' }, + svc: { + kind: 'fake/compute', + extension: 'test/pack', + name: 'test-service', + id: 'cps_1', + url: 'https://svc.example', + }, + }); + }); + + test('publishes only what the descriptor named — a secret in a node’s outputs never reaches the report', () => { + const result = run(lowering(root(), reportingExtension(), opts(svcBundles))); + + // The db's `url` is its connection string and the service's outputs carry a + // minted key; both are wired downstream and neither is published. + expect(JSON.stringify(result.outputs)).not.toContain('hunter2'); + expect(JSON.stringify(result.outputs)).not.toContain('sk_live_deploy'); + expect(JSON.stringify(result.outputs)).not.toContain('sk_live_quiet'); + }); + + test('a node whose descriptor reports nothing is absent, rather than guessed at', () => { + const result = run(lowering(root(), reportingExtension(), opts(svcBundles))); + + expect(Object.keys(result.outputs).sort()).toEqual(['db', 'svc']); + }); +}); diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index a87a06f5..61fc1feb 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -102,12 +102,28 @@ export interface LowerContext { readonly provisioned: ReadonlyMap; } +/** + * What a node publishes about the entity it became (ADR-0032) — the fields its + * own descriptor decided are safe to print. Include-only: a field the + * descriptor does not name is never published, so a field nobody thought about + * cannot leak. This is why core never derives a report from `outputs` itself: + * one field name can be a public address on one node kind and a credential on + * another, and only the descriptor that produced it knows which it made. + */ +export type NodeReport = Readonly>; + /** * What a lowering hands downstream — e.g. a deployed URL a later node's env * wiring consumes. The inter-node config-wiring hook for Connections. */ export interface LoweredNode { readonly outputs: Readonly>; + /** + * Safe facts about the entity this node became, for the deploy's report + * (ADR-0032). Absent means the node publishes nothing — the deliberate + * default, since silence is a visible gap and a guess could be a credential. + */ + readonly report?: NodeReport; } export interface LowerOptions { @@ -332,6 +348,20 @@ export function lowering( // hooks, before any node), then threaded read-only through every ctx and // into buildConfig — the same declare-then-mutate idiom as `lowered`. const provisioned = new Map(); + // ADR-0032: what each node became, keyed by its address — the stack's + // outputs. Core stamps the identity it already owns from the graph; the + // descriptor adds the facts only it knows about the entity it made (its + // platform id, and an address when that address is public). + const reports: Record = {}; + const recordReport = (id: NodeId, node: ServiceNode | ResourceNode, result: LoweredNode) => { + if (result.report === undefined) return; + reports[id] = { + kind: node.type, + extension: node.extension, + name: node.name, + ...result.report, + }; + }; // Each extension's application hook runs ONCE, before any node, in config // order — its outputs reach that extension's own nodes via ctx.application @@ -431,7 +461,9 @@ export function lowering( const descriptor = yield* descriptorFor(extensions, node, id); if (descriptor.kind === 'resource') { - lowered.set(id, yield* descriptor(ctx)); + const result = yield* descriptor(ctx); + lowered.set(id, result); + recordReport(id, node as ResourceNode, result); continue; } if (descriptor.kind !== 'service') { @@ -457,10 +489,14 @@ export function lowering( assembled: { dir: bundle.dir, entry: bundle.entry }, address: id, }); - lowered.set(id, yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized)); + const deployed = yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized); + lowered.set(id, deployed); + recordReport(id, service, deployed); } - return { outputs: {} }; + // The stack's outputs ARE the report (ADR-0032): alchemy prints them, so a + // deploy says what each node became instead of ending in `{ outputs: {} }`. + return { outputs: reports }; }) as Effect.Effect; } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 811fdb4a..97828a39 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -139,6 +139,16 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { }); return { outputs: { url: deployment.deployedUrl, projectId: provisioned.outputs['projectId'] }, + // ADR-0032. A compute service's address is public by construction — + // it is the endpoint callers hit — so it is the one thing worth + // reporting. The artifact hash and the service's environment stay + // out: the environment is where the secrets are. (kind/name/extension + // come from the graph, so core stamps those.) + report: { + id: provisioned.outputs['serviceId'], + url: deployment.deployedUrl, + projectId: provisioned.outputs['projectId'], + }, }; }), }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index 09f73f38..c336bc09 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts @@ -29,7 +29,14 @@ export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { // Warm the DB so a consumer's first connect doesn't eat PPG's cold-start // (FT-5226). `warm.url` is the same url, so consumers depend on the warm. const warm = yield* PgWarm(`${id}-warm`, { url }); - return { outputs: { url: warm.url } }; + return { + outputs: { url: warm.url }, + // ADR-0032: the database's id, and nothing else. This node's `url` is + // its connection string — a credential — so unlike a compute service's + // `url` it is never reported. Same field name, opposite sensitivity; + // that is why the descriptor decides and core does not. + report: { id: db.id }, + }; }); return Object.assign(lowering, { kind: 'resource' as const }); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts index c3bc6224..6c467020 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts @@ -59,7 +59,14 @@ export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { ...(node.targetRef !== undefined ? { refName: node.targetRef } : {}), }); - return { outputs: { url: warm.url } }; + return { + outputs: { url: warm.url }, + // ADR-0032: the database's id, and nothing else — `url` here is its + // connection string, not a public address. The contract hash and + // migration ref stay out too; they say nothing a reader of the deploy + // needs. + report: { id: db.id }, + }; }); return Object.assign(lowering, { kind: 'resource' as const }); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts index 94281206..0b34281f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts @@ -19,6 +19,11 @@ export function s3CredentialsDescriptor(_o: ResolvedCloudOptions): NodeDescripto const creds = yield* S3Credentials(`${id}-creds`, {}); return { outputs: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey }, + // ADR-0032: an empty report — this node is listed by the identity core + // stamps, and adds nothing, because its entire output is the minted key + // pair. Note neither secret is called `url`: a rule written over field + // names would print both. + report: {}, }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts index 96d8f21d..842d4797 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts @@ -65,6 +65,11 @@ export function s3StoreDescriptor(o: ResolvedCloudOptions): NodeDescriptor { accessKeyId: serialized.outputs['accessKeyId'], secretAccessKey: serialized.outputs['secretAccessKey'], }, + // ADR-0032: compute's report (the public endpoint) plus the bucket + // name. Deliberately NOT spread from `outputs`: this node is the case + // that breaks "a service's outputs are safe" — the two lines above it + // are the minted SigV4 pair. + report: { ...deployed.report, bucket: serialized.outputs['bucket'] }, }; }), };