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
56 changes: 56 additions & 0 deletions .drive/projects/rpc-service-key/plan.md
Original file line number Diff line number Diff line change
@@ -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 <serviceKey>` 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.
78 changes: 78 additions & 0 deletions .drive/projects/rpc-service-key/slices/slice-1-rpc-enforcement.md
Original file line number Diff line number Diff line change
@@ -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 <key>`
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 <key>` where `<key>` 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 <key>`; 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.
71 changes: 71 additions & 0 deletions .drive/projects/rpc-service-key/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Spec — RPC service keys

## Purpose

A Prisma Compute service is reachable at a public HTTPS URL, so an exposed
`/rpc/<method>` 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 <key>`.
`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/<method>` 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.
Loading
Loading