source-cooperative / source-cooperative/data.source.coop

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

Open
#210 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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 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 #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).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.