Skip to content

feat(rpc): verify RPC callers with a per-binding service key (ADR-0030)#89

Merged
wmadden-electric merged 5 commits into
mainfrom
claude/rpc-service-key-provision-84b838
Jul 15, 2026
Merged

feat(rpc): verify RPC callers with a per-binding service key (ADR-0030)#89
wmadden-electric merged 5 commits into
mainfrom
claude/rpc-service-key-provision-84b838

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

A Prisma Compute service is reachable at a public HTTPS URL, so an exposed /rpc/<method> endpoint answers anyone on the internet. This teaches RPC to answer only its wired peers: every RPC binding carries a distinct, framework-minted service key — the client sends it, serve() rejects a caller that doesn't present one (401). No authoring change; on by default once provisioned.

The decision and its rationale are in ADR-0030 (included here). In short: it's a per-binding capability token, not an ADR-0029 name-only secret — the framework mints the value and keeps it in the workspace deploy state, and it rides the binding's own COMPOSER_* env-var rail (like the URL), never process.env in user code.

This PR — slice 1 of 2: RPC-layer enforcement

Mostly packages/0-framework/2-authoring/rpc, plus one small target-serializer fix (below); fully unit-testable via the in-memory transport.

  • serve() checks each request against an accepted-key set (COMPOSER_RPC_ACCEPTED_KEYS, a reserved var read address-free) before body parse / method lookup / dispatch. Unset/empty = pass-through (the migration state); a non-empty set requires Authorization: Bearer <key> and 401s anything else. Membership is a length-independent constant-time compare, and process is declared structurally so the module keeps its no-node/bun-coupling property.
  • rpc() gains an optional serviceKey connection param alongside url; makeClient attaches Authorization: Bearer <key> when present.
  • RPC_ACCEPTED_KEYS_ENV is exported so slice 2's target writes the exact var the reader reads (same writer/reader-share-a-key pattern as the secret channel).
  • Target fix (descriptors/compute.ts): the optional serviceKey param surfaced a latent defect — the deploy serializer wrote an env var with an undefined value for any unprovisioned optional connection param, which PDP rejects (value: Required), failing the deploy of any app with an RPC binding. Now it skips an undefined-valued param, exactly as the runtime stash() already does. This is what makes slice 1 genuinely deploy-inert.

Inert until slice 2. With no accepted set configured, existing RPC round-trip behaviour is unchanged, so this is safe to merge on its own.

Branch was updated onto current main to pick up the COMPOSE_COMPOSER_ reserved-prefix rename that landed after it was cut.

Not in this PR — slice 2 (deploy provisioning)

Minting one key per RPC edge (an Alchemy ServiceKey resource, value stable in deploy state), the generic per-edge value channel in buildConfig, writing the consumer serviceKey / provider accepted-set env vars, and a live storefront-auth deploy proving the round trip with a wired-peer 200 and an anonymous-curl 401. Specced in .drive/projects/rpc-service-key/.

Tests

@internal/rpc unit + type tests green (bun test: 31 pass; bun run test:types: no errors; tsc --noEmit + biome clean). New cases: member key → dispatch, wrong/missing key → 401 with handler not run, 401-before-400 on bad input, empty set → pass-through, client attaches/omits the bearer header, serviceKey optional.

🤖 Generated with Claude Code

wmadden-electric and others added 2 commits July 15, 2026 20:08
Records the decision that every RPC binding carries a distinct,
framework-minted service key: the client sends it, serve() rejects a
caller without one (401). The key is auto-provisioned per consumer→provider
edge at deploy, rides the binding's own COMPOSE_* env-var rail (like the
URL), and its value is minted and kept in the workspace deploy state — a
capability token that verifies a wired peer over the already-TLS'd Compute
URL, deliberately NOT an ADR-0029 name-only secret.

Includes the project spec and the two-slice plan under
.drive/projects/rpc-service-key/ (slice 1: RPC-layer enforcement; slice 2:
deploy provisioning).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…0030)

serve() now checks each request against an accepted service-key set before
it parses the body or dispatches: an unset/empty set is the pass-through
migration state, a configured non-empty set requires Authorization: Bearer
<key> and 401s anything else. Membership is a length-independent
constant-time compare, and process is declared structurally so the module
keeps its no-node/bun-coupling property.

The consumer side rides the existing binding rail: rpc() gains an optional
serviceKey connection param alongside url, and makeClient attaches
Authorization: Bearer <key> when present. RPC_ACCEPTED_KEYS_ENV is exported
so slice 2's target can write the same reserved var the reader reads.

Inert until slice 2 provisions keys — no authoring change, existing
round-trip behaviour unchanged when no set is configured.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +26 to +27
export const RPC_ACCEPTED_KEYS_ENV = 'COMPOSE_RPC_ACCEPTED_KEYS';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a constant? I expected this to be unique to the node receiving it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two levels, and it is per-node where it counts.

The variable slice 2 writes is per-node: it goes through the same configKey(address, …) scheme as every other config value, so COMPOSE_<address>_RPC_ACCEPTED_KEYS — unique per service in the shared project namespace.

This constant is only the address-free key serve() reads at runtime. compute()'s run() re-stashes each node's config under address-free keys precisely so the app entry — which no longer knows its address — can read it; that is exactly how load()/config() work today. Exactly one service runs per Compute instance, so the address-free key is unambiguous, and serve() is never handed the address to read the scoped var directly.

So: per-node at the deploy/project level (slice 2), address-free at the process level (here). Happy to rename the constant to signal "runtime-stashed" if that reads clearer.

An optional connection param with no provisioned value (e.g. rpc()'s new
serviceKey before slice 2 wires it) resolved to undefined, and the compute
descriptor's serialize wrote an EnvironmentVariable with an undefined value
— which the platform rejects with "value: Required", failing the deploy of
any app with an RPC binding.

Skip a param whose resolved value is undefined, exactly as the runtime
stash() already does, so writer (deploy) and reader (boot) stay consistent:
no row is written, and boot's coerce() reads the missing var as absent, which
an optional param resolves to undefined. Closes a latent defect for any
optional connection param, not just serviceKey.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@89
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@89

commit: df486c0

wmadden-electric and others added 2 commits July 15, 2026 21:10
Align the RPC accepted-keys env var and docs with main's COMPOSE_ → COMPOSER_
reserved-prefix rename (ADR-0030 landed on a base predating it): the reader
constant becomes COMPOSER_RPC_ACCEPTED_KEYS, and the serialize regression test
asserts the COMPOSER_ keys the current serializer emits.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric merged commit a58a3fe into main Jul 15, 2026
15 checks passed
@wmadden-electric
wmadden-electric deleted the claude/rpc-service-key-provision-84b838 branch July 15, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants