diff --git a/.drive/projects/rpc-service-key/plan.md b/.drive/projects/rpc-service-key/plan.md new file mode 100644 index 00000000..4365b6d4 --- /dev/null +++ b/.drive/projects/rpc-service-key/plan.md @@ -0,0 +1,56 @@ +# Plan — RPC service keys + +Two slices, each one PR. Slice 1 lands the mechanism and the decision record and +is safe to merge alone; slice 2 wires provisioning and turns it on in a real +deploy. + +## Slice 1 — RPC-layer enforcement (`@internal/rpc` only) — THIS PR + +Self-contained, no deploy, fully unit-testable via the in-memory transport +(`serve()`'s handler bound straight into `makeClient`). + +- **`rpc()` / connection** — add a `serviceKey` connection parameter alongside + `url`, carrying the generic "auto-provisioned per binding" facet (a plain + param facet, per ADR-0018; the value is a capability token, not user config). + `hydrate({ url, serviceKey })` passes the key to `makeClient`. +- **`makeClient`** — attach `Authorization: Bearer ` to every request + when a key is present. +- **`serve()`** — read the provider's accepted key set from a reserved config + channel (define the reserved key name + JSON-array format now; slice 2 writes + it, mirroring how `secretKey`/`secretPointerRows` share a key between reader and + writer). Enforce: missing/unknown bearer → `401` before parse/dispatch; + constant-time membership check. When no accepted set is configured, pass through + unchanged — the migration state until slice 2 provisions keys (ADR-0030's + end-state semantics; the staged rollout is a plan detail, not in the ADR). +- **Tests** — in-memory transport: right key → dispatches; wrong/missing key → + `401`; unconfigured provider → passes through. Type test: the `serviceKey` + param is invisible to the authoring surface. +- **DoD** — `@internal/rpc` unit + type tests green; ADR-0030 + spec included; + no core/target change; behavior of existing deploys unchanged (inert until + slice 2). + +## Slice 2 — deploy provisioning (core + Prisma Cloud target + example) + +Turns the mechanism on end to end and proves it live. + +- **Core** — a generic edge-scoped provisioned value: a connection parameter + marked "auto-provisioned per binding" is not filled from the producer node's + outputs in `buildConfig`; the target mints one value per RPC edge instead. + Expose the RPC edges + the empty slot for the target to fill. Keep core free of + any `rpc`/`serviceKey` knowledge (react to the facet only). +- **Target (`@internal/composer-prisma-cloud` lowering)** — a per-edge `ServiceKey` + Alchemy resource (random value, stable in deploy state). Wire its value ref + into (a) the consumer's `serviceKey` `COMPOSER_*` variable and (b) the provider's + accepted-set `COMPOSER_*` variable, aggregating all inbound edges (Alchemy string + interpolation over the JSON array). +- **Example + live proof** — deploy `storefront-auth`, assert the wired round trip + returns `ok` and an anonymous `curl` returns `401`; second redeploy is a no-op. +- **DoD** — the spec's Definition of Done, in full. + +## Sequencing / safety + +Slice 1 is fail-open when unconfigured, so merging it changes no running deploy. +Slice 2 makes every provider carry keys, at which point enforcement is always +active. If we ever want a provider with no consumers to reject all external +calls (empty accepted set → deny), decide that in slice 2 with the live behavior +in front of us. diff --git a/.drive/projects/rpc-service-key/slices/slice-1-rpc-enforcement.md b/.drive/projects/rpc-service-key/slices/slice-1-rpc-enforcement.md new file mode 100644 index 00000000..fa4d929a --- /dev/null +++ b/.drive/projects/rpc-service-key/slices/slice-1-rpc-enforcement.md @@ -0,0 +1,78 @@ +# Slice 1 — RPC-layer service-key enforcement + +One PR. Scope: `packages/0-framework/2-authoring/rpc` only. No core, no target, +no example. Fully unit-testable via the in-memory transport. + +## What ships + +### 1. `serviceKey` connection parameter — `src/rpc.ts` + +- `rpc(contract)`'s connection gains a second param alongside `url`: + `serviceKey`, **optional** (unprovisioned deploys and existing tests must not + break). Use the existing param builder (`string()`); mark optional the way + `coerce()` in the target serializer expects (`param.optional === true`). +- `hydrate({ url, serviceKey })` passes the key through to `makeClient`. +- This param is internal wiring; it does **not** change the authoring surface + (`rpc(contract)` is still the whole call). + +### 2. Client attaches the key — `src/client.ts` + +- `makeClient(contract, url, opts?)` gains `opts.serviceKey?: string`. +- When a key is present, every request carries `Authorization: Bearer ` + in addition to the existing `content-type` header. When absent, no auth header + (the migration/inert state until slice 2 provisions keys). + +### 3. Server verifies — `src/serve.ts` + +- Export a constant for the reserved accepted-keys env var name, e.g. + `export const RPC_ACCEPTED_KEYS_ENV = 'COMPOSER_RPC_ACCEPTED_KEYS'`. The target + (slice 2) imports this to know what to write; the reader owns the name. +- `serve()` reads that env var (address-free — one served service per process, + same as the stashed config `load()`/`config()` read). Declare `process` + **structurally** at the top of the module, exactly like + `target/src/serializer.ts` does, so the package keeps its "no node/bun + coupling" property. +- Parse the value as a JSON array of strings = the accepted key set. + - **Unset or empty array** → enforcement off; behave exactly as today + (pass-through). This is the only state until slice 2. + - **Non-empty** → require `Authorization: Bearer ` where `` is a + member of the set. Missing header, malformed header, or non-member → + `401` (JSON `{ error }` body, same shape as the other error responses), + returned **before** body parse / method lookup / dispatch. +- Membership test is **constant-time**: compare the presented key against each + accepted key with a length-independent constant-time string equality (no + `node:crypto` — keep the module runtime-agnostic), OR the per-key results, + and always iterate the whole set. Good hygiene; the value is already a + 256-bit random token. + +## Tests + +Extend the existing `__tests__`: + +- `rpc-connection.test.ts`: `end.connection.params` now includes `serviceKey` + (update the `toEqual`). Add: `serviceKey` is optional. +- `client.test.ts` (or `serve.test.ts`): with a `serviceKey`, the outgoing + `Request` carries `Authorization: Bearer `; without one, it does not. +- `serve.test.ts`: drive enforcement through the in-memory transport. + - accepted set configured + client sends a member key → dispatches, `200`. + - configured + client sends wrong/no key → `401`, handler never runs. + - unset/empty accepted set → passes through (existing round-trip tests stay + green with no env set). + - `401` is returned before input validation (a bad-input body with no key is + `401`, not `400`). + - Use `beforeEach`/`afterEach` (or `try/finally`) to set and clear the + reserved env var so cases don't leak into each other or into the + pass-through tests. + +## Out of scope (slice 2) + +Minting keys, the per-edge value channel in `buildConfig`, the `ServiceKey` +Alchemy resource, writing the consumer `serviceKey` / provider accepted-set +env vars, and the live deploy proof. + +## DoD + +- `@internal/rpc` unit + type tests green (`bun test` in the package; repo + typecheck/lint clean). +- No change outside `packages/0-framework/2-authoring/rpc`. +- Existing RPC round-trip behavior unchanged when no accepted set is configured. diff --git a/.drive/projects/rpc-service-key/spec.md b/.drive/projects/rpc-service-key/spec.md new file mode 100644 index 00000000..a14b305b --- /dev/null +++ b/.drive/projects/rpc-service-key/spec.md @@ -0,0 +1,71 @@ +# Spec — RPC service keys + +## Purpose + +A Prisma Compute service is reachable at a public HTTPS URL, so an exposed +`/rpc/` endpoint answers anyone on the internet. Make an RPC provider +answer only its **wired peers**: the consumers this application actually +connected to it. The framework mints a distinct unguessable **service key** per +RPC binding at deploy, the client sends it, the provider checks it. No authoring +change; on by default. + +The design decision and its rationale are recorded in +[ADR-0030](../../../docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md). +This spec is the build contract for it. + +## Requirements + +1. **Client attaches, server verifies.** The consumer's hydrated RPC client + sends its binding's key on every call as `Authorization: Bearer `. + `serve()` rejects a request whose bearer token is not one of the keys issued + to the provider's callers with `401`, before it parses the body or dispatches. + The comparison is constant-time. +2. **Per binding.** Each consumer→provider RPC edge gets a distinct key. The + provider verifies against the *set* of keys issued to its inbound edges + (membership), not a single value. +3. **Auto-provisioned, no authoring surface.** The developer never declares, + names, reads, or sees the key. It is minted at deploy and wired to both ends + by the framework. +4. **Rides the binding's env rail.** The consumer's key is a `serviceKey` + connection parameter alongside `url`, serialized to a reserved `COMPOSER_*` + variable and hydrated into the client through the host shim — never read from + `process.env` in user code (No-globals principle). The provider's accepted set + is a reserved `COMPOSER_*` variable read by `serve()` through the same shim. +5. **Value in deploy state, stable across redeploys.** The key is minted once per + edge and held in the workspace-hosted deploy state store, not the user-facing + Prisma Cloud project. A no-op redeploy stays a no-op (the key doesn't churn); + the two ends never disagree mid-deploy. +6. **Generic, not RPC-special-cased in core/target.** Core and the Prisma Cloud + target handle "a connection parameter auto-provisioned per binding" as a + generic facet. Only the `@internal/rpc` package knows this facet means "RPC + peer key" and enforces it on the wire. + +## Non-goals + +- **Per-method / per-contract authorization.** The key gates at the service + level. Splitting a service is the way to get independent surfaces. +- **Protecting data at rest / a real secret.** The key is a capability token; its + value legitimately lives in deploy state (contrast ADR-0029 secrets). +- **Rotation UX.** Rotation is "destroy the key/binding and redeploy." No rotate + command in scope. +- **Non-RPC transports.** HTTP (`http()`) bindings are untouched here. + +## Definition of Done + +- Deploying `examples/storefront-auth` (or an equivalent two-service example) + provisions per-edge keys, and the live round trip still succeeds with + enforcement on: a wired consumer call returns `ok`, and a direct anonymous + `curl` to the provider's `/rpc/` returns `401`. +- A second no-op redeploy re-versions nothing (keys are stable in state). +- Unit + type tests cover the `@internal/rpc` enforcement and the target wiring. +- ADR-0030 merged; this spec's requirements each map to a shipped, tested slice. + +## Key risk + +The per-edge value channel (requirement 6). A binding's URL is one provider +output every consumer copies; a per-binding key is scoped to the *edge*, so it is +not a provider output and does not flow through the existing +producer-output→consumer-param path in `buildConfig`. Slice 2 introduces the +generic edge-scoped provisioned-value mechanism and the provider-side +aggregation. This is where the design earns or loses its "dead simple" claim; +plan it against the real `buildConfig`/`serialize`/graph code before writing it. diff --git a/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md b/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md new file mode 100644 index 00000000..16dda6fc --- /dev/null +++ b/docs/design/90-decisions/ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md @@ -0,0 +1,154 @@ +# ADR-0030: RPC callers are verified with an auto-provisioned per-binding service key + +## Decision + +Every RPC binding carries a distinct, framework-minted **service key** — an +unguessable random value the deploy provisions automatically. The consumer's +generated client sends it on every call; the provider's `serve()` handler +rejects any request that doesn't carry one of the keys issued to its callers, +before it dispatches. Nothing in application code declares, names, reads, or +even sees the key. + +```ts +// Provider — unchanged authoring. serve() now refuses a caller that +// doesn't present a key issued to it (401), before any handler runs. +export const api = compute({ name: 'api', expose: { orders }, /* … */ }); +export default serve(api, { orders: { place: async (input, deps) => /* … */ } }); + +// Consumer — unchanged authoring. The hydrated client attaches the key +// on every call; the developer never touches it. +const client = orders; // rpc(orders) dependency, hydrated +await client.place({ sku, qty }); // sends Authorization: Bearer +``` + +The key rides the **same wire the binding's URL already rides**: it is a second +connection parameter on the RPC dependency (`serviceKey`, alongside `url`), +serialized to a reserved `COMPOSER_*` environment variable and hydrated into the +client through the framework's host shim — the developer's code never reads the +environment (see the *No globals* principle). Its value is minted at deploy and +kept in the workspace-hosted deploy state store, so it is stable across +redeploys and never appears in the Prisma Cloud project's own variable list. + +## Reasoning + +A Prisma Compute service is reachable at a public HTTPS URL. Transport is +already encrypted, but nothing stops an anonymous request on the internet from +reaching an exposed `/rpc/` endpoint. We want the provider to answer +only its **wired peers** — the services this application actually connected to +it — and to turn away everyone else. The bar is deliberately low: prove "you are +a service this app wired to call me," not "you are a specific principal with +specific rights." An unguessable shared value does exactly that and no more. + +**Why a distinct key per binding, not one per provider.** The natural cheap +design gives each provider a single key that all of its consumers share. We go +finer: each consumer→provider edge gets its own key. The wiring cost is small — +the provider validates an incoming key against the *set* of keys it issued +(a constant-time membership check) instead of against a single value — and it +buys real least-privilege: two consumers of the same provider hold different +keys, so one leaking its key never lets it impersonate the other, and a single +edge can be rotated without touching the rest. A consumer only ever physically +holds keys for the providers it declared as dependencies, so an application can +never reach a provider it wasn't wired to — that property holds at either +granularity, but per-binding is the one that also isolates peers from each +other. + +**Why it is not a secret in the ADR-0029 sense.** [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) +draws a hard line: for a real secret, the framework carries the *name* and never +the *value* — the value is provisioned out of band and injected by the platform, +so it never lands in deploy state, generated programs, or logs. A service key is +different in kind. It is not a credential protecting data at rest; it is a +per-deployment capability token whose only job is to separate a wired peer from +an anonymous caller over an already-encrypted channel. That lower bar lets the +framework do the thing ADR-0029 forbids for secrets: **mint the value itself and +keep it in deploy state.** The deploy state store is the workspace-hosted +`prisma-composer-state` project (ADR-0009) — not the user-facing Prisma Cloud +project — so the value stays out of the surface a developer reads, and "transient +state for this deployment" is exactly what it is. + +**Why the env-var rail, not the artifact.** The value has to reach both running +instances. A Compute version takes its environment from the project's config +variables — there is no version-scoped env channel — so the only two doors into +a running instance are a project environment variable or a file baked into the +deploy artifact. Baking it in keeps it out of the project's variable list, but a +per-deploy value baked into the artifact changes the artifact's hash on every +deploy, which breaks no-op-redeploy detection, and it splits one value across two +independently built artifacts. The env-var rail is the one the binding's URL +already uses, it keeps the artifact reproducible, and one more reserved +`COMPOSER_*` variable is consistent with the config the framework already writes +there. The value being visible to someone with project access is acceptable +given what the value is. + +**Why enforcement lives in `serve()` and the client, not in a proxy.** The RPC +kind already owns both ends of the wire — `serve()` generates the provider's +fetch handler and `makeClient` generates the consumer's. Attaching the key on +the way out and checking it on the way in is a few lines at each end, needs no +new network hop, and keeps the check adjacent to the dispatch it guards. The +provider compares with a constant-time equality so the check itself leaks +nothing about which keys are valid. + +**Where the per-binding value comes from.** A binding's URL is a single value the +provider produces and every consumer copies — it flows cleanly as one provider +output. A per-binding key cannot: each consumer needs a *different* value, so it +is not a provider output at all but a value scoped to the *edge*. The framework +mints one key per RPC edge at deploy, wires that edge's key into the consumer's +`serviceKey` connection parameter, and aggregates every inbound edge's key into +the provider's accepted-set variable. This is the one genuinely new piece of +wiring the decision requires; it is generic (a connection parameter marked +"auto-provisioned per binding"), not RPC-specific, so the target provisions and +serializes it without knowing what an RPC is. + +## Consequences + +- An RPC provider answers only wired peers. An anonymous or wrong-key request + gets `401` before any handler runs. This is on by default for every RPC + binding; there is no opt-in and no authoring change. +- The key's value lives in deploy state. Anyone who can read the workspace's + deploy state, or the provider's project environment, can read a key. That is + the accepted bound: a service key gates access, it does not protect data. +- Rotation is: destroy the edge's key (or the binding) and redeploy, which mints + a fresh value. Because both ends are wired from the same deploy, they are never + out of step mid-rotation. +- The provider carries one accepted key per inbound RPC edge. Adding a consumer + re-versions the provider, because the provider must learn the new caller's key + — the correct behavior, not a surprise. +- The key authorizes at the service level, not per method: `serve()` flattens + every exposed contract into one `/rpc/` namespace, so a valid key + reaches every method on the service. Per-method scoping is a separate authz + feature and is out of scope; a provider that wants two independently gated + surfaces exposes two services. + +## Alternatives considered + +- **One key per provider, shared by all its consumers.** Simpler — the key is a + plain provider output that flows exactly like the URL, and the provider checks + a single value. Rejected because it gives up per-peer isolation for a wiring + saving that turned out to be small: a set-membership check and per-edge minting + are not much more than a scalar and one mint. +- **A real ADR-0029 secret.** Model the key as a `secret()` need bound to a + platform variable. Rejected: ADR-0029 secrets are provisioned out of band — + there is no path for the framework to *mint* the value, which is the whole + ask — and the redaction/pointer machinery is weight this capability token + doesn't need. +- **Bake the key into the deploy artifact.** Keeps it out of the project's + variable list. Rejected: a per-deploy value in the artifact changes its hash + every deploy (breaking no-op redeploys) and would have to be injected into two + separately built artifacts. The predictable, reproducible choice is the env + rail. +- **Per-method capability keys.** Distinct authorization per exposed method. + Rejected as out of scope: that is an authorization system, well beyond an + unguessable value, and the same effect is available today by splitting a + service. + +## Related + +- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — the secret slot this + qualifies: a service key is a minted, deploy-state value, deliberately *not* a + name-only secret. +- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — the + workspace-hosted state store the key's value lives in. +- [ADR-0015](ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) — + the binding the key rides on, alongside the URL. +- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the target owns + serializing the `serviceKey` parameter and the accepted-set variable. +- The *No globals — all dependencies are injected* architectural principle — why + the key reaches the client through hydration, never `process.env` in user code. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index e3497582..2a7e17e2 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -51,3 +51,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) — Ship two **public** packages: `@prisma/composer` (core + CLI + agnostic subpaths) and `@prisma/composer-prisma-cloud` (target + first-party modules as entrypoints, cron first). Boundary = the user's one choice: where does it run. - [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — `packages/` organizes into numbered domains (0-framework, 1-prisma-cloud, 9-public) and layers; planes as config-mapped entrypoints; internals are `@internal/*`; only 9-public publishes; dependency-cruiser enforces. - [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)* diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts index 8c2e5d7a..5de4fe61 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts @@ -83,4 +83,37 @@ describe('makeClient()', () => { expect(typeof client.verify).toBe('function'); }); + + test('a serviceKey adds Authorization: Bearer to every request', async () => { + const requests: Request[] = []; + const client = makeClient(authContract, 'http://auth.internal', { + serviceKey: 'edge-key', + fetch: async (req) => { + requests.push(req); + return new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await client.verify({ token: 't' }); + + expect(requests[0]?.headers.get('authorization')).toBe('Bearer edge-key'); + }); + + test('no serviceKey means no Authorization header', async () => { + const requests: Request[] = []; + const client = makeClient(authContract, 'http://auth.internal', { + fetch: async (req) => { + requests.push(req); + return new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await client.verify({ token: 't' }); + + expect(requests[0]?.headers.has('authorization')).toBe(false); + }); }); diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts index cd3d2cd9..14211f20 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts @@ -15,7 +15,16 @@ describe('rpc(contract) — the dependency end', () => { expect(isNode(end)).toBe(true); expect(end.kind).toBe('dependency'); expect(end.type).toBe('rpc'); - expect(end.connection.params).toEqual({ url: string() }); + expect(end.connection.params).toEqual({ + url: string(), + serviceKey: string({ optional: true }), + }); + }); + + test('serviceKey is optional', () => { + const end = rpc(authContract); + + expect(end.connection.params['serviceKey']?.optional).toBe(true); }); test('hydrate synchronously binds a client with a callable method per contract method', () => { diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts index e207ea3a..5b606a41 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, test } from 'bun:test'; import type { DependencyEnd, RunnableServiceNode } from '@internal/core'; import { dependency, service } from '@internal/core'; import { type } from 'arktype'; import { makeClient } from '../client.ts'; import { contract } from '../contract.ts'; import { rpc } from '../rpc.ts'; -import { serve } from '../serve.ts'; +import { RPC_ACCEPTED_KEYS_ENV, serve } from '../serve.ts'; const authContract = contract({ verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), @@ -141,3 +141,107 @@ describe('serve()', () => { expect(loadCalls).toBe(1); }); }); + +describe('serve() — service-key enforcement (COMPOSER_RPC_ACCEPTED_KEYS)', () => { + afterEach(() => { + delete process.env[RPC_ACCEPTED_KEYS_ENV]; + }); + + test('a member key dispatches normally', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); + const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); + const handler = serve(authService, { + rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + }); + const client = makeClient(authContract, 'http://auth.internal', { + fetch: handler, + serviceKey: 'good-key', + }); + + await expect(client.verify({ token: 'good-token' })).resolves.toEqual({ ok: true }); + }); + + test('a wrong key is rejected with 401 and the handler never runs', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); + let handlerCalled = false; + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { + rpc: { + verify: async () => { + handlerCalled = true; + return { ok: true }; + }, + }, + }); + + const res = await handler( + new Request('http://auth.internal/rpc/verify', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer wrong-key' }, + body: JSON.stringify({ token: 't' }), + }), + ); + + expect(res.status).toBe(401); + expect(handlerCalled).toBe(false); + }); + + test('a missing key is rejected with 401 and the handler never runs', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); + let handlerCalled = false; + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { + rpc: { + verify: async () => { + handlerCalled = true; + return { ok: true }; + }, + }, + }); + + const res = await handler( + new Request('http://auth.internal/rpc/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 't' }), + }), + ); + + expect(res.status).toBe(401); + expect(handlerCalled).toBe(false); + }); + + test('401 fires before input validation — a bad-input body with no key is 401, not 400', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { rpc: { verify: async () => ({ ok: true }) } }); + + const res = await handler( + new Request('http://auth.internal/rpc/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 123 }), + }), + ); + + expect(res.status).toBe(401); + }); + + test('an empty accepted-keys array passes through unchanged', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify([]); + const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); + const handler = serve(authService, { + rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + }); + + const res = await handler( + new Request('http://auth.internal/rpc/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'good-token' }), + }), + ); + + expect(res.status).toBe(200); + }); +}); diff --git a/packages/0-framework/2-authoring/rpc/src/client.ts b/packages/0-framework/2-authoring/rpc/src/client.ts index f7290eb5..10a2e044 100644 --- a/packages/0-framework/2-authoring/rpc/src/client.ts +++ b/packages/0-framework/2-authoring/rpc/src/client.ts @@ -4,7 +4,9 @@ * runtime value (rpc()'s `{ input, output }`), POSTs JSON to * `/rpc/`, and validates the response against the output schema * before returning it (a provider can be typed-compatible and still lie at - * runtime — this is the per-call layer that catches that). + * runtime — this is the per-call layer that catches that). When a + * `serviceKey` is supplied (ADR-0030), every request also carries + * `Authorization: Bearer `. */ import type { Contract } from '@internal/core'; @@ -47,9 +49,13 @@ async function errorDetail(res: Response): Promise { export function makeClient>( contract: C, url: string, - opts?: { fetch?: Transport }, + opts?: { fetch?: Transport; serviceKey?: string }, ): Client { const send = opts?.fetch ?? fetch; + const headers: Record = { 'content-type': 'application/json' }; + if (opts?.serviceKey !== undefined) { + headers['Authorization'] = `Bearer ${opts.serviceKey}`; + } const client: Record Promise> = {}; for (const [method, schemas] of Object.entries( @@ -62,7 +68,7 @@ export function makeClient>( const res = await send( new Request(methodUrl(url, method), { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers, body: JSON.stringify(input), }), ); diff --git a/packages/0-framework/2-authoring/rpc/src/index.ts b/packages/0-framework/2-authoring/rpc/src/index.ts index 24a2a469..ca839af5 100644 --- a/packages/0-framework/2-authoring/rpc/src/index.ts +++ b/packages/0-framework/2-authoring/rpc/src/index.ts @@ -13,4 +13,4 @@ export { contract } from './contract.ts'; export type { Client } from './rpc.ts'; export { rpc } from './rpc.ts'; export type { Handlers } from './serve.ts'; -export { serve } from './serve.ts'; +export { RPC_ACCEPTED_KEYS_ENV, serve } from './serve.ts'; diff --git a/packages/0-framework/2-authoring/rpc/src/rpc.ts b/packages/0-framework/2-authoring/rpc/src/rpc.ts index 5753d046..3710bb8d 100644 --- a/packages/0-framework/2-authoring/rpc/src/rpc.ts +++ b/packages/0-framework/2-authoring/rpc/src/rpc.ts @@ -7,10 +7,12 @@ * the two schemas for serve()/the client to read back out. * * A Contract instead types a dependency end — the typed sibling - * of `http()`, same `{ url }` param, hydrating to the typed client `Client` - * over the network binding in client.ts. It carries the contract as its - * `required` value, so `ModuleBuilder.provision`'s wiring is checked against it - * (compile time) and Load's `satisfies()` backstop re-checks it (runtime). + * of `http()`, same `{ url }` param plus an optional `serviceKey` (the + * per-binding auth token ADR-0030 wires through here), hydrating to the typed + * client `Client` over the network binding in client.ts. It carries the + * contract as its `required` value, so `ModuleBuilder.provision`'s wiring is + * checked against it (compile time) and Load's `satisfies()` backstop + * re-checks it (runtime). */ import type { Contract } from '@internal/core'; import { type DependencyEnd, dependency, string } from '@internal/core'; @@ -34,8 +36,8 @@ export function rpc( return dependency({ type: 'rpc', connection: { - params: { url: string() }, - hydrate: ({ url }) => makeClient(arg, url), + params: { url: string(), serviceKey: string({ optional: true }) }, + hydrate: ({ url, serviceKey }) => makeClient(arg, url, { serviceKey }), }, required: arg, }); diff --git a/packages/0-framework/2-authoring/rpc/src/serve.ts b/packages/0-framework/2-authoring/rpc/src/serve.ts index fddc9305..8e5b2e93 100644 --- a/packages/0-framework/2-authoring/rpc/src/serve.ts +++ b/packages/0-framework/2-authoring/rpc/src/serve.ts @@ -7,6 +7,10 @@ * `S["load"]`'s return, so an incomplete or mistyped `serve(service, * handlers)` call does not compile; extra handler methods/ports are allowed * (width, same as a provider exposing more than a consumer requires). + * + * Per ADR-0030, every request is checked against the accepted service-key + * set before dispatch: unset/empty is the pass-through migration state, + * a configured non-empty set requires `Authorization: Bearer `. */ import type { Contract, Expose, RunnableServiceNode } from '@internal/core'; @@ -14,6 +18,13 @@ import { blindCast } from '@internal/foundation/casts'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { standardValidate } from './standard-schema.ts'; +// The ambient environment of whatever runtime hosts the bundle. Declared +// structurally so this entry imports no runtime's types. +declare const process: { env: Record }; + +/** The reserved env var the target (slice 2) writes the accepted key set to. */ +export const RPC_ACCEPTED_KEYS_ENV = 'COMPOSER_RPC_ACCEPTED_KEYS'; + // biome-ignore lint/suspicious/noExplicitAny: accepts any concrete runnable service node — generics are invariant, so `any` is required (mirrors ModuleBuilder.provision in @prisma/composer). type AnyRunnable = RunnableServiceNode; @@ -47,6 +58,54 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +/** The configured accepted key set, or `[]` if unset/empty/malformed — the pass-through state. */ +function acceptedKeys(): readonly string[] { + const raw = process.env[RPC_ACCEPTED_KEYS_ENV]; + if (raw === undefined || raw === '') return []; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + return Array.isArray(parsed) && parsed.every((key): key is string => typeof key === 'string') + ? parsed + : []; +} + +/** + * Length-independent constant-time string equality — no early exit on the + * first mismatched character or on a length difference, so a caller cannot + * time its way toward a valid key. No `node:crypto`, to keep this module + * runtime-agnostic. + */ +function constantTimeEquals(a: string, b: string): boolean { + const length = Math.max(a.length, b.length); + let diff = a.length ^ b.length; + for (let i = 0; i < length; i++) { + diff |= (i < a.length ? a.charCodeAt(i) : 0) ^ (i < b.length ? b.charCodeAt(i) : 0); + } + return diff === 0; +} + +/** Whether `presented` is a member of `accepted` — always compares against every key. */ +function isAcceptedKey(presented: string, accepted: readonly string[]): boolean { + let matched = false; + for (const key of accepted) { + matched = constantTimeEquals(presented, key) || matched; + } + return matched; +} + +const BEARER_PREFIX = 'Bearer '; + +/** The bearer token on `Authorization`, or `''` if the header is missing or malformed. */ +function bearerToken(req: Request): string { + const header = req.headers.get('authorization'); + return header?.startsWith(BEARER_PREFIX) ? header.slice(BEARER_PREFIX.length) : ''; +} + /** * Flattens every exposed port's methods into one method → {schemas, handler} * table. RPC dispatch is flat (`/rpc/`), so a method name exposed by @@ -103,6 +162,11 @@ export function serve>( const deps = service.load(); return async (req: Request): Promise => { + const accepted = acceptedKeys(); + if (accepted.length > 0 && !isAcceptedKey(bearerToken(req), accepted)) { + return jsonResponse({ error: 'Unauthorized: missing or invalid service key' }, 401); + } + const { pathname } = new URL(req.url); const methodName = /^\/rpc\/([^/]+)$/.exec(pathname)?.[1]; if (methodName === undefined) { diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 30433946..fa999566 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -90,7 +90,7 @@ const { prismaCloud } = await import('../control.ts'); const { compute, envSecret, postgres, postgresContract, s3StoreService } = await import( '../index.ts' ); -const { module, secret } = await import('@internal/core'); +const { dependency, module, secret, string } = await import('@internal/core'); const { lowering } = await import('@internal/core/deploy'); const run = (eff: Effect.Effect): A => @@ -372,6 +372,55 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); + test('an optional connection param with no provisioned value writes NO env-var row; a provided one still does', async () => { + await withEnv({ PRISMA_BRANCH_ID: undefined }, () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + // A dependency shaped like rpc()'s post-slice-1 connection: a required + // url plus an optional serviceKey the deploy has not provisioned yet. + const authDep = dependency({ + type: 'rpc', + connection: { + params: { url: string(), serviceKey: string({ optional: true }) }, + hydrate: (v) => v, + }, + }); + const node = compute({ + name: 'test-service', + deps: { auth: authDep }, + build: { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }); + const ctx = { address: 'consumer', node, graph: { secrets: [] } } as unknown as LowerContext; + const provisioned: LoweredNode = { + outputs: { serviceId: 'consumer-svc#cloud-id', projectId: 'shop-project#cloud-id' }, + }; + // buildConfig resolves url from the wired provider; serviceKey has no value yet. + const config = { + service: { port: 3000 }, + inputs: { auth: { url: 'http://auth.internal', serviceKey: undefined } }, + }; + const before = recorded.envVar.length; + + run(serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config)); + + const writes = recorded.envVar.slice(before).map(([, props]) => props); + // The provided url still writes its row... + expect(writes).toContainEqual({ + projectId: 'shop-project#cloud-id', + key: 'COMPOSER_CONSUMER_AUTH_URL', + value: 'http://auth.internal', + class: 'production', + }); + // ...but the unprovisioned serviceKey writes none — no "value: Required" at deploy. + const writtenKeys = writes.map((p) => (p as { key: string }).key); + expect(writtenKeys).not.toContain('COMPOSER_CONSUMER_AUTH_SERVICEKEY'); + }); + }); + test('a secret slot serializes to a POINTER row — value is the bound platform NAME, never a value (ADR-0029)', async () => { await withEnv( // The real secret value is present in the deploy shell, proving it still 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 e2489609..0a91e195 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 @@ -41,6 +41,10 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { for (const d of paramEntries(svc)) { const value = d.owner === 'service' ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name]; + // An unprovisioned optional connection param has no value yet — write + // no row (boot's coerce() reads a missing var as absent → undefined). + // Mirrors stash(), keeping writer and reader consistent. + if (value === undefined) continue; const key = configKey(address, d); records.push( yield* Prisma.EnvironmentVariable(`${key}-var`, {