source-cooperative / source-cooperative/data.source.coop
feat(backend-auth): read backend credentials from Cloudflare Secrets Store (R2 / S3 access keys)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 24
- Forks
- 6
- Avg merge
- 1h 32m
- Merged PRs (30d)
- 1
Description
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
authenticationdiffers. - Add the
s3_access_keyvariant toBackendAuth, carrying a reference (or ciphertext), never a plaintext value in aDeserializestruct that could reach a log line. - Resolve to credentials and insert
access_key_id/secret_access_keyintobackend_options; leaveskip_signatureunset so the request is signed. - Gate on
backend_type == "s3", mirroring theProviderMismatchcheckS3WebIdentityRolealready 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 #148.
- Keep
BackendAuth::kind()returning a bare label; no key material in spans, logs, orDebug. - Note in CI docs: a deploy token needs Account Secrets Store Edit — Cloudflare treats binding a secret to a Worker as a write, so
Readfails at deploy time. This does not give the running Worker write access; bindings only exposeget(). - 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).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by resolving the Secrets Store design choice with source.coop, since the authentication wire shape is still undecided. Then read src/backend_auth.rs, multistore/src/types.rs, and the credential-cache work in #148; define the credential variant and cache behavior around those entry points. Done means signed S3 reads work for R2 or MinIO, failures remain closed, and credentials are absent from logs, spans, and responses.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- authentication, backend, cloud
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100