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
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).
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) understandsunsignedands3_web_identity_role. The Source API already defines ans3_access_keyauthentication type (DataConnectionAuthenticationType.S3AccessKey), anddeserialize_lenientmaps it toUnsupported, whichapply_backend_authfails closed on withUnsupportedAuthType. So today a connection pointed at R2 with credentials is a 403 by construction.R2 also needs
region: "auto"plus a customendpoint— both already expressible on the connectiondetails, 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 —
multistorealready acceptsaccess_key_id/secret_access_keyas backend options (multistore/src/types.rs, the S3 option list), soapply_backend_authjust inserts two more entries and leavesskip_signatureunset.Secrets Store bindings are static. A Worker declares each secret in its Wrangler config:
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 (Readgrants 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
authenticationpayload becomes something like:{ "type": "s3_access_key", "ciphertext": "<base64>", "key_id": "dataconn-v1" }key_idnames the bound secret, so key rotation is a config change and a re-encrypt sweep rather than a flag day.Work
authenticationdiffers.s3_access_keyvariant toBackendAuth, carrying a reference (or ciphertext), never a plaintext value in aDeserializestruct that could reach a log line.access_key_id/secret_access_keyintobackend_options; leaveskip_signatureunset so the request is signed.backend_type == "s3", mirroring theProviderMismatchcheckS3WebIdentityRolealready does.BackendAuth::kind()returning a bare label; no key material in spans, logs, orDebug.Readfails at deploy time. This does not give the running Worker write access; bindings only exposeget().Acceptance
authentication.type = "s3_access_key"pointed at an R2 bucket serves signed reads through the proxy.BackendAuthError), never falling back to unsigned.Related: #137 (federated backend auth epic), #148 (credential cache), #207 (connection hardening).