feat(rpc): verify RPC callers with a per-binding service key (ADR-0030)#89
Conversation
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>
| export const RPC_ACCEPTED_KEYS_ENV = 'COMPOSE_RPC_ACCEPTED_KEYS'; | ||
|
|
There was a problem hiding this comment.
Why is this a constant? I expected this to be unique to the node receiving it
There was a problem hiding this comment.
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>
commit: |
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>
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), neverprocess.envin 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 requiresAuthorization: Bearer <key>and401s anything else. Membership is a length-independent constant-time compare, andprocessis declared structurally so the module keeps its no-node/bun-coupling property.rpc()gains an optionalserviceKeyconnection param alongsideurl;makeClientattachesAuthorization: Bearer <key>when present.RPC_ACCEPTED_KEYS_ENVis exported so slice 2's target writes the exact var the reader reads (same writer/reader-share-a-key pattern as the secret channel).descriptors/compute.ts): the optionalserviceKeyparam surfaced a latent defect — the deploy serializer wrote an env var with anundefinedvalue for any unprovisioned optional connection param, which PDP rejects (value: Required), failing the deploy of any app with an RPC binding. Now it skips anundefined-valued param, exactly as the runtimestash()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.
Not in this PR — slice 2 (deploy provisioning)
Minting one key per RPC edge (an Alchemy
ServiceKeyresource, value stable in deploy state), the generic per-edge value channel inbuildConfig, writing the consumerserviceKey/ provider accepted-set env vars, and a livestorefront-authdeploy proving the round trip with a wired-peer200and an anonymous-curl401. Specced in.drive/projects/rpc-service-key/.Tests
@internal/rpcunit + type tests green (bun test: 31 pass;bun run test:types: no errors;tsc --noEmit+biomeclean). New cases: member key → dispatch, wrong/missing key →401with handler not run,401-before-400on bad input, empty set → pass-through, client attaches/omits the bearer header,serviceKeyoptional.🤖 Generated with Claude Code