Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2606590
feat(sts): let platform IdP tokens act as accounts that trust them
alukach Sep 25, 2026
d7bf1da
docs(adrs): record platform issuers and their trust path
alukach Sep 25, 2026
e96907c
docs(sts): say exactly which trust answers are cached
alukach Sep 25, 2026
aded1ac
fix(sts): name only service accounts and bound replayed platform tokens
alukach Sep 25, 2026
89ae5b1
refactor(sts): apply review suggestions on platform token exchange
alukach Oct 1, 2026
ec3f79c
fix(sts): escape caller text in exchange error bodies (#246)
alukach Oct 1, 2026
21f0250
refactor(sts): parse exchange parameters once for both short-circuits
alukach Oct 1, 2026
c117168
fix(sts): refuse a platform token sent in the URL
alukach Oct 1, 2026
8ccf862
fix(config): accept PLATFORM_ISSUERS as a TOML table
alukach Oct 1, 2026
cc81361
fix(sts): rate-limit only platform exchanges the trust cache misses
alukach Oct 1, 2026
2a1171c
fix(sts): make a platform issuer's key failures retryable
alukach Oct 1, 2026
6e2a5b7
docs: bring exchange comments in line with the code
alukach Oct 1, 2026
6c9126d
test: drop a redundant assertion from the service-account test
alukach Oct 1, 2026
e484541
refactor(sts): drop the Exchange and PlatformToken structs
alukach Oct 1, 2026
6404e42
refactor(cache): inline the trust answer helpers
alukach Oct 1, 2026
8938bb2
test(ci): cover the person route and per-issuer audiences
alukach Oct 1, 2026
2ee4431
ci(staging): mint the smoke-test token only with a trust account
alukach Oct 1, 2026
dc8b141
ci: give the person-issuer worker its own dev host
alukach Oct 1, 2026
d8613f7
refactor(sts): inline the key id lookup
alukach Oct 1, 2026
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
45 changes: 35 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
permissions:
contents: read
# Mint a GitHub OIDC token as the write tests' caller identity — the
# worker verifies it against GitHub's JWKS (AUTH_ISSUER below).
# worker verifies it against GitHub's JWKS (PLATFORM_ISSUERS below).
id-token: write
steps:
- uses: actions/checkout@v4
Expand All @@ -106,16 +106,23 @@ jobs:
# dependency in this job can't mint federation assertions any AWS role
# trusts. Federated end-to-end coverage lives in the deployed-environment
# smoke tests (tests/test_federation.py, wired into staging.yml) instead.
# Inbound callers authenticate with GitHub Actions OIDC tokens:
# AUTH_ISSUER points at GitHub and AUTH_AUDIENCE matches the audience
# requested in "Mint caller identity token" below.
# Inbound callers authenticate with GitHub Actions OIDC tokens. GitHub
# is a platform issuer, as in production: PLATFORM_ISSUERS names the
# audience requested in "Mint caller identity token" below, and a
# token acts as an account the stub says trusts this repository.
# AUTH_ISSUER names an issuer that mints nothing. AUTH_AUDIENCE is the
# wrong-audience token's audience: if the platform path ever checked
# the person audiences instead of GitHub's own, test_writes.py's
# audience tests would fail. The person route runs on a second worker;
# see "Start wrangler dev".
run: |
{
openssl genpkey -algorithm RSA -out /tmp/oidc.pem -pkeyopt rsa_keygen_bits:2048
printf 'OIDC_PROVIDER_KEY="%s"\n' "$(cat /tmp/oidc.pem)"
echo "SESSION_TOKEN_KEY=$(openssl rand -base64 32)"
echo "AUTH_ISSUER=https://token.actions.githubusercontent.com"
echo "AUTH_AUDIENCE=source-data-proxy-ci"
echo "AUTH_ISSUER=https://auth.example.invalid"
echo "AUTH_AUDIENCE=not-the-data-proxy"
echo 'PLATFORM_ISSUERS={"https://token.actions.githubusercontent.com": ["source-data-proxy-ci"]}'
echo "SOURCE_API_URL=http://localhost:9000"
} > .dev.vars
- name: Mint caller identity token (GitHub OIDC)
Expand Down Expand Up @@ -144,9 +151,13 @@ jobs:
fi
echo "::add-mask::$token"
echo "CI_WRITE_ID_TOKEN=$token" >> "$GITHUB_ENV"
# A second, validly-signed token whose audience mismatches
# AUTH_AUDIENCE: test_writes.py asserts the /.sts aud gate rejects
# it (signature checks alone would let it through).
# The stub trusts exactly this token's subject, as source.coop
# matches a trust (see tests/stub_api.py).
sub=$(python3 -c 'import base64,json,sys; p=sys.argv[1].split(".")[1]; print(json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))["sub"])' "$token")
echo "CI_TRUSTED_SUBJECT=$sub" >> "$GITHUB_ENV"
# A second, validly-signed token whose audience is not GitHub's in
# PLATFORM_ISSUERS: test_writes.py asserts the /.sts aud gate
# rejects it (signature checks alone would let it through).
wrong=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=not-the-data-proxy" | jq -r '.value')
if [ -z "$wrong" ] || [ "$wrong" = "null" ]; then
Expand All @@ -171,8 +182,17 @@ jobs:
# may appear later in the config are untouched.
sed -i '/^\[build\]/,/^\[/ s/^command = .*/command = "echo skip"/' wrangler.toml
wrangler dev --port 8787 > /tmp/wrangler.log 2>&1 &
# A second worker whose person issuer is GitHub, so CI's token
# exercises the person route (tests/test_person_route.py). Its
# PLATFORM_ISSUERS still names GitHub, which the proxy drops at load.
# --host overrides wrangler.toml's localhost:8787, which SigV4 would
# otherwise be verified against.
wrangler dev --port 8788 --host localhost:8788 --inspector-port 9230 --persist-to /tmp/wrangler-person \
--var AUTH_ISSUER:https://token.actions.githubusercontent.com \
--var AUTH_AUDIENCE:source-data-proxy-ci > /tmp/wrangler-person.log 2>&1 &
curl --retry 120 --retry-delay 1 --retry-connrefused --silent --fail http://localhost:8787/ > /dev/null
echo "Server ready"
curl --retry 120 --retry-delay 1 --retry-connrefused --silent --fail http://localhost:8788/ > /dev/null
echo "Servers ready"
- name: Run integration tests
# Contract tests are excluded: they hit the live prod API, so they run
# on a schedule instead (.github/workflows/contract.yml) — a prod
Expand All @@ -182,6 +202,8 @@ jobs:
# var went missing (renamed, mint plumbing broken), the tests fail
# loudly instead of skipping green (see tests/test_writes.py).
CI_EXPECT_OIDC: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
# Unset on fork PRs, which have no token to exchange there.
PERSON_PROXY_URL: ${{ env.CI_WRITE_ID_TOKEN && 'http://localhost:8788' || '' }}
run: uvx --with requests --with boto3 pytest tests/ -v --ignore=tests/test_contract.py
- name: Dump server logs
# Both servers run in the background, so their output isn't captured
Expand All @@ -197,6 +219,9 @@ jobs:
echo "::group::wrangler dev"
cat /tmp/wrangler.log || true
echo "::endgroup::"
echo "::group::wrangler dev (person issuer)"
cat /tmp/wrangler-person.log || true
echo "::endgroup::"

audit:
name: Security Audit
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,14 @@ jobs:
# A GitHub Actions OIDC token, minted per run — short-lived by design
# and never stored, unlike a token parked in a repo secret.
#
# Dormant until Source registers GitHub as a valid IdP, so that products
# can accept writes from GitHub Actions. Two things must land first:
# the deployment's AUTH_ISSUER must accept GitHub's issuer (today it is
# a single Ory URL — src/config.rs reads AUTH_ISSUER as one String,
# unlike the comma-separated AUTH_AUDIENCE, so this needs a code change
# too), and the audience Source expects must be set as
# FEDERATION_TEST_AUDIENCE. Until then no token is minted and the
# copy-source authz test skips; the rest of the suite is unaffected.
if: vars.FEDERATION_TEST_AUDIENCE != ''
# Dormant until FEDERATION_TEST_AUDIENCE is set to the staging proxy's
# origin, the audience staging's PLATFORM_ISSUERS accepts for GitHub,
# and FEDERATION_TEST_TRUST_ACCOUNT to a staging service account that
# trusts this repository's workflows (ADR-014): the token acts as that
# account. Until both are set no token is minted and the copy-source
# authz test skips; the rest of the suite is unaffected. (A token with
# no trust account would name the stub's, which staging lacks.)
if: vars.FEDERATION_TEST_AUDIENCE != '' && vars.FEDERATION_TEST_TRUST_ACCOUNT != ''
run: |
set -euo pipefail
token=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
Expand All @@ -88,6 +87,7 @@ jobs:
FEDERATION_WRITE_PRODUCT: ${{ vars.FEDERATION_WRITE_PRODUCT }}
# Set by the mint step above, and only when it runs.
CI_WRITE_ID_TOKEN: ${{ env.CI_WRITE_ID_TOKEN }}
CI_TRUST_ACCOUNT: ${{ vars.FEDERATION_TEST_TRUST_ACCOUNT }}
# boto3: the copy-source authz test signs SigV4 through the AWS SDK
# rather than hand-rolling requests.
run: uvx --with requests --with boto3 pytest tests/test_federation.py -v
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ percent-encoding = "2"
hmac = "0.12"
sha2 = "0.10"

# Reading a platform IdP's token before verifying it (issuer, key id)
base64 = "0.22"

# Tracing
tracing = "0.1"

Expand All @@ -77,6 +80,9 @@ multistore-cf-workers = { version = "0.7.2", features = ["azure", "gcp"] }
# unnecessary here. The `form` feature gates `.form()`, which the STS
# `post_form` call in FetchHttpExchange needs.
reqwest = { version = "0.13", default-features = false, features = ["form"] }
# Racing an issuer's key fetch against a timeout (`fetch_keys`); already in
# the tree via worker.
futures-util = { version = "0.3", default-features = false }
console_error_panic_hook = "0.1"
js-sys = "0.3"
wasm-bindgen = "0.2"
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ Set in `wrangler.toml` or via the Cloudflare dashboard:
| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `SOURCE_API_URL` | `https://source.coop` | Source Cooperative API base URL |
| `LOG_LEVEL` | `WARN` | Tracing level (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`) |
| `AUTH_ISSUER` | `https://auth.source.coop` | OIDC issuer trusted for `/.sts` token exchange |
| `AUTH_AUDIENCE` | — | Comma-separated OAuth client ID(s) that `/.sts` subject tokens must be issued to (`aud` claim); a token is accepted if it matches any. Unset = `/.sts` token exchange is disabled (returns 501) |
| `AUTH_ISSUER` | `https://auth.source.coop` | The person issuer trusted for `/.sts` token exchange; its tokens act as their own subject |
| `AUTH_AUDIENCE` | — | Comma-separated OAuth client ID(s) that `/.sts` subject tokens must be issued to (`aud` claim); a token is accepted if it matches any. Unset = person-token exchange at `/.sts` is disabled (returns 501); API keys and platform tokens still exchange |
| `PLATFORM_ISSUERS` | — | JSON object from each platform issuer URL to the audiences its tokens must carry, such as `{"https://token.actions.githubusercontent.com": ["https://data.source.coop"]}`, written as a string or as a TOML table. An issuer with no audience is refused. Unset = no platform issuer is trusted |
| `OIDC_PROVIDER_ISSUER` | `https://data.source.coop` | Issuer URL for minted JWTs and OIDC discovery |
| `OIDC_PROVIDER_KID` | `data-proxy-1` | Key ID for the active signing key |
| `OIDC_PROVIDER_KID_PREVIOUS` | — | Key ID for the previous key (during rotation) |
Expand All @@ -120,15 +121,15 @@ Set in `wrangler.toml` or via the Cloudflare dashboard:

| Binding | Kind | Description |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEY_EXCHANGE_LIMIT` | `ratelimit` | Per-client-IP limit on API-key exchanges at `/.sts` (ADR-013). Declared under `[[ratelimits]]` in every `wrangler*.toml`; a deployment without it logs an error and exchanges without a limit |
| `STS_EXCHANGE_LIMIT` | `ratelimit` | Per-client-IP limit on `/.sts` exchanges that may cost a Source API call: every API-key exchange (ADR-013), and each platform-token exchange whose trust answer is not cached (ADR-014). Declared under `[[ratelimits]]` in every `wrangler*.toml`; a deployment without it logs an error and exchanges without a limit |

### API keys

A service account's API key (ADR-013) is an opaque `sck_` secret that source.coop stores as a hash. It is presented at `/.sts` as `WebIdentityToken`, from a POST form body only — a key in the URL is refused, because the URL is logged. The proxy trims it and checks its shape and checksum (the last six characters are a CRC-32 of the thirty random ones before them, in base62), hashes it, and asks `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` for its standing as itself (subject `urn:source:data-proxy`), caching the answer for 60 seconds; then it mints credentials for the account the API names, exactly as it would for an ID token. A key that fails its shape or checksum was cut short or mistyped, and is refused as such without a lookup; every other refusal of the key reads `API key was not accepted (request id …)`, and the reason is in the log under that id.

### Roles

Every exchange at `/.sts`, of an ID token or an API key, names a Role in `RoleArn`, either bare or as the resource of an ARN of any partition and account (`arn:aws:iam::000000000000:role/ReadOnly`), since AWS SDKs insist on an ARN. The Roles are hardcoded (ADR-014):
Every exchange at `/.sts`, of an ID token or an API key, names a Role in `RoleArn`, either bare or as the resource of an ARN of any partition and account (`arn:aws:iam::000000000000:role/ReadOnly`), since AWS SDKs insist on an ARN. The account matters only to a platform token (below). The Roles are hardcoded (ADR-014):

| Role | Credentials may |
| ------------ | -------------------------------------------------------- |
Expand All @@ -138,6 +139,12 @@ Every exchange at `/.sts`, of an ID token or an API key, names a Role in `RoleAr

Any other name is refused with `MalformedPolicyDocument`, never mapped to a default. A Role only subtracts: its ceiling is sealed into the session token and checked locally before the account's own permissions are looked up (ADR-011), and a request it refuses gets the same `AccessDenied` as any other refusal.

### Platform identity providers

A token from a platform issuer in `PLATFORM_ISSUERS`, such as GitHub Actions, says which workload is calling but not which account it may act as. It is presented at `/.sts` from a POST form body only, as an API key is: a token in the URL is refused, because the URL is logged. It acts as the service account in `RoleArn`, `arn:aws:iam::<owner>--<name>:role/FullAccess`, and only if that account trusts the token's issuer and subject (ADR-014); an account that is not a service account is refused before anything else. The proxy verifies the token against the issuer's JWKS (looked up again for a key id it has not seen; a failure to fetch it is a retryable `InternalError`), with that issuer's own audiences and a required `exp`, then, unless a cached answer settles it and within `STS_EXCHANGE_LIMIT`, asks `POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges` with `{"issuer", "subject"}`, as the account. Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10, and the credentials' principal is the account, never the token's subject. Every refusal reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`. A token from `AUTH_ISSUER` still acts as its own subject and ignores the account in `RoleArn`.

`aws-actions/configure-aws-credentials` fails after the exchange succeeds: it checks the credentials it exports with `GetCallerIdentity`, which the proxy cannot answer until developmentseed/multistore#126 lands. Until then a workflow saves its token to a file and lets an AWS SDK exchange it, with `AWS_WEB_IDENTITY_TOKEN_FILE`, `AWS_ROLE_ARN`, `AWS_ENDPOINT_URL_STS=<proxy>/.sts`, `AWS_ENDPOINT_URL_S3=<proxy>` and `AWS_REGION`.

### Secrets

**GitHub environment secrets are the source of truth.** The deploy workflow
Expand Down
4 changes: 2 additions & 2 deletions adrs/001-s3-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The sealed payload carries:
| `secret_access_key` | The signing secret, recovered by unsealing |
| `expiration` | Enforced at unseal time; an expired token fails closed |
| `assumed_role_id` | The Role assumed at exchange time: `_default`, `FullAccess` or `ReadOnly` (ADR-004) |
| `source_identity` | The original OIDC `sub` — the caller's Ory identity |
| `source_identity` | Who the credentials act as: an Ory ID token's `sub`, or the account an API key or a trusted platform token names (ADR-013, ADR-014) |
| `allowed_scopes` | The Role's ceiling, sealed at mint time: empty for `FullAccess` and `_default`, reads of every product for `ReadOnly` (see below) |
| `session_token` | A discarded random placeholder. The credential set is sealed *before* this field is overwritten with the sealed blob, so the value inside the envelope is not the token itself |

Expand All @@ -67,7 +67,7 @@ Key properties of this design:
- **Verification is fully stateless.** The proxy decrypts the token on each request and recovers the `SecretAccessKey` directly. No database lookup, no key derivation, and no asymmetric verification on the request hot path — which matters on Workers, where in-memory state does not persist across invocations.
- **The token is opaque to the caller.** Unlike a JWT, a client cannot read the sealed payload. Scope and identity metadata are not disclosed to whoever holds the credential.
- **`allowed_scopes` is enforced by the bucket registry, not by multistore.** multistore's own consumer, `multistore::auth::authorize`, has no call site in the pinned crate; the gateway delegates authorization to the registry instead (ADR-005), which checks the ceiling before any lookup (ADR-011, #236). The registry reads an empty vec as **no ceiling**, the reverse of `authorize`, where empty means deny-all — which is also why the registry overrides `authorize_key` rather than inheriting the default. The only non-empty ceiling is `ReadOnly`'s: every product (`*`), read actions only.
- **`source_identity` preserves the original subject**, which is what the proxy presents to the policy store (see ADR-005).
- **`source_identity` is the principal**, which is what the proxy presents to the policy store (see ADR-005): the original subject for an Ory ID token, never a platform token's subject.
- **Authenticated encryption.** GCM provides integrity as well as confidentiality: a tampered token fails to decrypt rather than decoding into attacker-chosen values.

### SigV4 Verification Flow
Expand Down
Loading
Loading