Skip to content

feat(backend-auth): read backend credentials from Cloudflare Secrets Store (R2 / S3 access keys) #210

Description

@alukach

Problem

S3-compatible backends that have no OIDC federation path — Cloudflare R2 above all — can only be reached with a long-lived access key pair. The proxy has no way to use one.

BackendAuth (src/backend_auth.rs) understands unsigned and s3_web_identity_role. The Source API already defines an s3_access_key authentication type (DataConnectionAuthenticationType.S3AccessKey), and deserialize_lenient maps it to Unsupported, which apply_backend_auth fails closed on with UnsupportedAuthType. So today a connection pointed at R2 with credentials is a 403 by construction.

R2 also needs region: "auto" plus a custom endpoint — both already expressible on the connection details, so only the credential half is missing.

Ask

Let the proxy read per-connection backend credentials from Cloudflare Secrets Store and sign backend requests with them. The proxy is read-only against the store: it never creates, updates, or deletes a secret. Writes belong to source.coop (see source-cooperative/source.coop#485).

The read side is cheap once the value is in hand — multistore already accepts access_key_id / secret_access_key as backend options (multistore/src/types.rs, the S3 option list), so apply_backend_auth just inserts two more entries and leaves skip_signature unset.

⚠️ The part that needs a decision first

Secrets Store bindings are static. A Worker declares each secret in its Wrangler config:

[[secrets_store_secrets]]
binding = "SOME_SECRET"
store_id = "..."
secret_name = "some-secret"

and reads it with await env.SOME_SECRET.get(). There is no runtime lookup by name, and the Secrets Store REST API never returns a secret's value (Read grants metadata only). A per-connection secret therefore cannot be resolved dynamically at request time.

Two ways out:

(a) One bound secret per connection. Creating a data connection means writing the secret and redeploying the proxy with a new binding. Deploy latency on every connection create, a Wrangler config that grows without bound, and the account cap (currently 100 secrets per account, one store per account in beta) becomes a hard ceiling on key-based connections.

(b) One bound key, ciphertext on the connection record. Secrets Store holds a single long-lived key bound to the proxy under a fixed name. source.coop encrypts each connection's credentials with it and stores only the ciphertext in DynamoDB; the API serves that ciphertext as part of authentication, and the proxy decrypts in-isolate. One secret total, no redeploys, no per-connection ceiling, and no plaintext credential at rest anywhere.

Recommendation: (b). If the keypair is asymmetric (source.coop holds the public half, the proxy the private half), the intended capability split is enforced by the cryptography rather than by IAM scoping alone: source.coop can encrypt but never decrypt, the proxy can decrypt but never write.

Under (b) the authentication payload becomes something like:

{ "type": "s3_access_key", "ciphertext": "<base64>", "key_id": "dataconn-v1" }

key_id names the bound secret, so key rotation is a config change and a re-encrypt sweep rather than a flag day.

Work

  • Settle (a) vs (b) with source.coop before implementing — the wire shape of authentication differs.
  • Add the s3_access_key variant to BackendAuth, carrying a reference (or ciphertext), never a plaintext value in a Deserialize struct that could reach a log line.
  • Resolve to credentials and insert access_key_id / secret_access_key into backend_options; leave skip_signature unset so the request is signed.
  • Gate on backend_type == "s3", mirroring the ProviderMismatch check S3WebIdentityRole already does.
  • Cache the resolved value per isolate (the KEK under (b), which is stable) — the same caching shape as the federated credential cache, and subject to the same cross-request-lock hazard noted in Federated credential cache is per-isolate; consider cross-instance caching (KV/DO) + single-flight #148.
  • Keep BackendAuth::kind() returning a bare label; no key material in spans, logs, or Debug.
  • Note in CI docs: a deploy token needs Account Secrets Store Edit — Cloudflare treats binding a secret to a Worker as a write, so Read fails at deploy time. This does not give the running Worker write access; bindings only expose get().
  • Integration test against an R2 (or MinIO) bucket that rejects unsigned requests.

Acceptance

  • A data connection with authentication.type = "s3_access_key" pointed at an R2 bucket serves signed reads through the proxy.
  • A malformed or unresolvable credential still fails closed (BackendAuthError), never falling back to unsigned.
  • No credential material appears in logs, spans, or error responses.

Related: #137 (federated backend auth epic), #148 (credential cache), #207 (connection hardening).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions