feat(sts): serve the FullAccess and ReadOnly roles - #236
Conversation
|
Claude finished @alukach's task in 14s —— View job ✅ No blocking issues — safe to merge. I read the
Simplify (ponytail)
💰 Estimated review cost: $0.13 · 0m14s · 4 turns |
|
🚀 Latest commit deployed to https://source-data-proxy-pr-236.source-coop.workers.dev
|
## What I'm changing `cargo audit` fails on `main`, and so the Security Audit check fails on every open PR, including the machine-identity PRs (#232, #235). The cause is a new advisory against rustls 0.23.42, [RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285): TLS 1.3 handshake messages were accepted across encryption-level boundaries. It is patched in 0.23.45. This bumps the lockfile to it. ## How I did it `cargo update -p rustls --precise 0.23.45`. A plain `cargo update -p rustls` stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1, aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. `Cargo.lock` only. rustls never reaches the Worker. `cargo tree -i rustls --target wasm32-unknown-unknown` prints nothing; natively it comes in through multistore → reqwest → hyper-rustls, which the native tests use. Production was not exposed. ## How to test it - `cargo audit`: no vulnerabilities, with the one allowed warning (chacha20) CI already allows. - The pre-commit hook: `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, `cargo check --target wasm32-unknown-unknown` and `cargo test`, all passing. ## PR Checklist - [x] This PR has **no** breaking changes. - [x] I have updated or added new tests to cover the changes in this PR. (None apply: lockfile only.) - [x] This PR does not affect the Source Cooperative Frontend & API. ## Related Issues Unblocks the Security Audit check on #232, #235 and the PRs stacked on #235 (#236, #237); their pull-request runs check out the merge with `main`, so a re-run passes once this lands. #220 would catch the next one on a schedule. Part of source-cooperative/source.coop#491 only in that it clears CI for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1b6a91e to
7e65cc8
Compare
7e65cc8 to
79a7afb
Compare
79a7afb to
f2b5b4c
Compare
`/.sts` now resolves `RoleArn` to one of three hardcoded Roles, for ID tokens and API keys alike: `FullAccess`, the unlimited Role that `_default` has been; `ReadOnly`, whose sealed ceiling allows only reads; and `_default`, kept as an alias of `FullAccess` because deployed clients name it. Each is accepted bare or as the `role/<name>` resource of an ARN of any partition and account, since SDKs insist on an ARN. Any other name is `RoleNotFound`, never a fallback to a default. ReadOnly's ceiling rides in the session token's `allowed_scopes` as one scope over every product (`*`) that lists the read actions. multistore's own scope check never runs on this gateway, so the registry enforces it: `get_bucket` checks the ceiling before anything is fetched and refuses with the same `AccessDenied` as every other refusal (ADR-011). The ceiling only subtracts, so a write it allows still needs the account's write permission. No scopes means no ceiling, which keeps every `_default` session already issued working unchanged. source.coop's GitHub integration snippet already names `role/FullAccess`; this is what makes that name resolve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
ADR-004 described a single built-in `_default` Role, and ADR-001 said the sealed `allowed_scopes` was always empty and consulted nowhere, with empty meaning deny-all wherever scopes are evaluated. Both are dated by the hardcoded `FullAccess` and `ReadOnly` Roles: ADR-004 gains a note pointing to ADR-014, and ADR-001 now says the bucket registry enforces the ceiling and reads an empty one as no ceiling. ADR-011's status records that its step 2 and denial semantics are implemented for `ReadOnly`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
f2b5b4c to
b426567
Compare
|
Rebased onto |
## What I'm changing
ADR-013 now specifies an API key as `sck_`, 30 random base62 characters
and a six-character checksum: a fixed 40 characters matching
`^sck_[0-9A-Za-z]{36}$`, in place of `sck_` and 43 base64url characters
with no checksum. The checksum is the CRC-32 of the 30 random characters
(IEEE, as zlib computes it), written in base62 with the digits
`0-9A-Za-z`, most significant first, padded with `0` to six. This is
GitHub's own token layout.
## Why
GitHub's [secret-scanning partner
program](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
recommends three things for a secret format: a unique prefix, high
entropy, and a 32-bit checksum. The ADR already had the first two. The
checksum lets a scanner, the proxy or the CLI tell a real key from a
look-alike, a truncated key or a mistyped one without asking
source.coop, and so gives a mistyped key a refusal of its own. It adds
no security, since anyone can compute it; the ADR says so. Base62 keeps
`-` out of the key, so a double-click selects all of it: about half of
the base64url keys contained one.
The random part is 178 bits, down from 256; the ADR's "no salt or KDF"
and "enumeration is infeasible" arguments still hold at that size, and
the text now says 178.
The proxy's step 2 gains the checksum check and the distinct refusal,
"API key is malformed; check that it was copied whole", which reveals
nothing because the format is public.
ADR-013 is revised in place, as it was on 2026-09-25, because nothing
implementing it has shipped: source.coop issues keys since
source-cooperative/source.coop#570 merged, but no deployed proxy can
exchange one until #235 lands.
## Implementing PRs
- #235 checks the checksum at `/.sts`
(commits d6745e0 and 30ad22f); #236 and #237 are rebased onto it.
- source-cooperative/source.coop#596 generates keys in the new format
and shows the checksum as a key's hint;
source-cooperative/source.coop#581, stacked on it, validates leaked keys
by checksum. What to send GitHub, and the equivalent steps for other
scanners, are logged on source-cooperative/source.coop#561.
- source-cooperative/source-coop-cli#20 checks a key file's format and
checksum before exchanging it.
## Docs and ADRs
Only ADR-013 states the key format; I checked the other ADRs with `git
grep sck_` and none mention it. ADR-014's amendment of ADR-013 is about
ownership, not format, and still holds.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
) > [!IMPORTANT] > - **On `main`**, now that #235 and #236 are merged. This PR's own CI runs the integration tests that need CI's GitHub token. > - **Blocked externally for `aws-actions/configure-aws-credentials`.** That action, which source.coop's settings page hands out, checks the credentials it exports with `GetCallerIdentity`. The proxy cannot answer that call until developmentseed/multistore#126 lands, so the action fails after a successful exchange. An AWS SDK's own web-identity provider works now, as does any direct `AssumeRoleWithWebIdentity` call. > - **No multistore bump.** multistore 0.7.2 is enough: the one fail-closed check this needs, a required `exp`, is made here. See "Decisions to flag". ## What I'm changing A token from a **platform issuer**, GitHub Actions to begin with, can now be exchanged at `/.sts`. It acts as the service account its `RoleArn` names, `arn:aws:iam::<owner>--<name>:role/FullAccess`, and only if that account trusts the token's issuer and subject (ADR-014). The path runs ahead of the STS route, next to the API-key exchange. Both share one parse of the request, and the token is trimmed, so a token file ending in a newline works. 1. **Route by issuer.** The token's `iss` is read unverified. If it is in `PLATFORM_ISSUERS`, this path takes the token; otherwise the STS route does, unchanged. 2. **Body only.** As with API keys, the token must come in the form body. One in the URL is refused with `InvalidParameterValue`, because Cloudflare logs URLs and a logged token could be replayed until it expires. 3. **Check the request locally.** The Role must be one the proxy serves (#236), and `RoleArn` must name an account. That account must be a **service account**, `{owner}--{name}` as source.coop's `SERVICE_ACCOUNT_ID_REGEX` defines it (82 characters at most). Any other account is refused as an untrusting one is, before the token is verified or anything is signed as it. 4. **Verify the token** against the issuer's JWKS: signature, issuer, **that issuer's own audiences**, `nbf`, and a **required** `exp`. A key id missing from the cached keys is looked up once more, so a key GitHub has just rotated in works at once. A failed or timed-out key fetch is a retryable `InternalError`, not `InvalidIdentityToken`. 5. **Ask the account.** `POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges` with the verified `{issuer, subject}`, authenticated as the account, which is the contract on source.coop `main` (source-cooperative/source.coop#566). Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10. Only a lookup the cache can't answer is charged to `STS_EXCHANGE_LIMIT`, 100 a minute per client address, shared with API-key exchanges. 6. **Mint** under the named Role, with the **account as the principal**, never the token's subject. Every refusal of the trust reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`, and the proxy logs the account, issuer and subject at WARN. Nothing unverified reaches the Source API: a token that fails steps 2 to 4 costs no lookup. Every refusal at `/.sts` from the API-key and platform paths now carries the request id, and its message is XML-escaped (#246): it can echo `RoleArn` or a token's `kid`. The person route's errors still come from multistore's unescaped builder (developmentseed/multistore#160). **Configuration (#223).** `PLATFORM_ISSUERS` is a JSON object from issuer to the audiences its tokens must carry, written as a string or as a TOML table. The audiences are per issuer, so one issuer's audience never admits another's token (ADR-009). An issuer with no audience is refused, and a value that doesn't parse trusts none. An entry for `AUTH_ISSUER` is dropped at load with an error, since it would take every person token away from the STS route. `AUTH_ISSUER` and `AUTH_AUDIENCE` still configure the person issuer, whose tokens act as their own subject and ignore `RoleArn`'s account. An empty `AUTH_AUDIENCE` now disables only the person route with 501; API keys and platform tokens still exchange. | Config | Person issuer | Platform issuers | | --- | --- | --- | | production (`wrangler.toml`) | Ory, as before | GitHub, audience `https://data.source.coop` | | staging (`[env.staging]`) | staging Ory, as before | GitHub, audience `https://data.staging.source.coop` | | previews (`wrangler.preview.toml`) | staging Ory, as before | GitHub, audience `https://data.staging.source.coop` | | CI, main worker (`ci.yml`'s `.dev.vars`) | `https://auth.example.invalid`, which mints nothing, audience `not-the-data-proxy` | GitHub, audience `source-data-proxy-ci` | | CI, second worker (port 8788) | GitHub, audience `source-data-proxy-ci` | none: the GitHub entry is dropped as `AUTH_ISSUER` | GitHub's audience is the proxy's origin because source.coop's workflow snippet mints the token with `audience: <proxy origin>`. A preview's hostname changes per PR, so previews accept staging's origin, as they already accept staging's Ory clients. **Why one PR for #222 and #223.** The trust check (#222) is reachable only once a second issuer is configured (#223). The configuration is safe only with the trust check: without it, a GitHub token would act as its own subject under an unlimited Role, the risk ADR-009's important note describes. Neither is useful or safe alone. **#222's issue body is superseded by its later comment**, the source-cooperative/source.coop#566 contract. The body asked for an issuer-qualified principal. Under the comment's contract, a platform token's subject never becomes a principal at all: the principal is the account that trusts it. The trust answer is cached per issuer, so two issuers' identical subjects cannot share an entry, which is what #222's "done when" asks. **Decisions to flag** - **Only a service account can be named.** The minted principal is the account segment as given, and source.coop resolves a principal as an Ory identity first. An Ory UUID fits the person and organisation handle grammar, so if a trust ever existed on such an account, platform credentials would act as a person. source.coop writes trusts only to service accounts today, so this is defense in depth, and it matches ADR-014. The `000000000000` placeholder from `_default`'s ARN is refused here too. - **A yes is cached for 60 seconds, a no for 10, under one key.** `cached_fetch` caches 200s only, and the route says no with a 403, or a 401 for an account it can't resolve. That refusal is stored as `{"trusted": false}` under the same key, through a `cache_put` extracted from `cached_fetch`. So replaying one token costs about one lookup per 10 seconds per account, issuer and subject in each data center, and a trust just added works within 10 seconds. - **The body is read as well as the status.** A 200 whose body says `trusted: false` is refused and mints nothing. It is cached like any 200, for 60 seconds; the route never sends one. - **The rate limit is charged only on a cache miss.** Anyone can mint a GitHub token for this audience, so a lookup is bounded like a flood of junk keys. #235's `KEY_EXCHANGE_LIMIT` becomes `STS_EXCHANGE_LIMIT`, one budget shared by both kinds of exchange, with the same namespace ids. A cached answer costs the Source API nothing and isn't charged, so a job matrix behind one NAT address sharing a subject stays under it. Naming a different account each time still costs a lookup and is charged. - **Key fetches are bounded and retried, not refused.** The fetch races `STS_REQUEST_TIMEOUT` (10s). An unknown key id is looked up again through a second key cache held a minute, so a forged key id costs GitHub at most one fetch a minute per isolate. After a failed fetch, multistore backs off for 30 seconds and keeps no older keys, so a GitHub outage returns 500s for that long. - **A 404 from the trusts route is a 500, not a refusal.** The route answers for any account (401 when it doesn't exist), so a 404 means the API doesn't serve the route at all. That is a deployment mismatch, the same for every account, so it reveals nothing about any one account. - **401 and 403 read the same to the caller.** source.coop answers 401 for an account that doesn't exist, and also when it can't authenticate the proxy. Both are refused like "not trusted" and cached for 10 seconds. The WARN line has the account, issuer and subject, and source.coop's log says which case it was. - **The proxy vouches for the account before it is established.** To ask the question, it signs its usual on-behalf-of assertion as the account the caller names, and sends it only to that account's trusts route. This is ADR-014's design. ADR-005 describes such assertions only for callers the proxy has already authenticated (see Docs and ADRs). - **`exp` is required here, and multistore is not bumped.** multistore 0.7.2's `verify_token` checks `exp` only when present, the exposure ADR-004's warning says must be closed before other issuers are admitted. Upstream closes it in developmentseed/multistore#146, released as 1.0.0 by developmentseed/multistore#153, not yet merged. This path needs only that one check (`platform::subject`). Bumping would also bring multistore `main`'s `BucketConfig::backend_type` enum (developmentseed/multistore#155) into the registry for nothing this PR needs. The person path keeps 0.7.2's behaviour, which is harmless while Ory always sets `exp`. Drop the check when the bump happens. - **CI covers both routes with GitHub's token.** On the main worker the token takes the platform path, as in production. A second worker names GitHub as `AUTH_ISSUER`, so the same token is a person token there. The main worker's `AUTH_AUDIENCE` is the audience of the wrong-audience token CI already mints: a platform path that checked the person audiences instead of GitHub's own would accept that token and refuse the real one. - **Successful exchanges are logged at INFO**, which production drops. A cached yes never reaches source.coop, so nothing durable records which workflow minted a set of credentials. Not addressed here. ## How I did it - `src/platform.rs` (new, wasm-free): - `parse_issuers`, from a JSON string or the table's object; - `unverified`, the header and claims, for routing and the key id; - `verify`, multistore-sts's `find_key` and `verify_token` against keys it is given; - `subject`, which requires `exp` and a non-empty `sub`. - `src/sts.rs`: `account(role_arn)`, the ARN's account segment, and `is_service_account_id`, source.coop's grammar without a regex crate. - `src/source_api/cache.rs`: - `get_or_fetch_trust`, through `cached_fetch` as the account, with the 10-second refusal entry; - `cached_trust`, the cache read alone, so the rate limit is charged only on a miss; - `cache_put`, extracted from `cached_fetch`. - `src/lib.rs`: - **Dispatch:** `/.sts` parses its parameters once, trims the token, and passes them to `api_key_exchange` and `platform_exchange`. The 501 for an empty `AUTH_AUDIENCE` comes after both. - **Platform path:** `platform_exchange` is the whole platform path. `platform_keys` and `fetch_keys` add the timeout and the second key cache, through `futures-util` (already in the lockfile through worker) and `worker::Delay`. - **Errors:** `sts_refusal` and `sts_error_xml` build every exchange error, escaped and with the request id. - **Rate limit:** `STS_EXCHANGE_LIMIT` replaces `KEY_EXCHANGE_LIMIT`. - `src/config.rs`: `PLATFORM_ISSUERS`, read with `var` and, for a table, `object_var`, without `AUTH_ISSUER`. `Cargo.toml`: `base64` (already in the lockfile through multistore-sts) and `futures-util`. - **Wrangler config:** - `wrangler.toml` (production and staging) and `wrangler.preview.toml` get `PLATFORM_ISSUERS` and the renamed rate-limit binding. - The binding keeps the `[[ratelimits]]` form #245 moved it to for wrangler 4, with the same namespace ids. - **README:** the variable, the binding, and a "Platform identity providers" section with the `GetCallerIdentity` caveat. - `.github/workflows/ci.yml`: - `.dev.vars` as in the table above. - The mint step exports the token's `sub` as `CI_TRUSTED_SUBJECT`. - A second `wrangler dev` runs on 8788, with its own `--host`, because `wrangler.toml`'s dev host is `localhost:8787` and SigV4 is verified against it. - `.github/workflows/staging.yml`: the dormant federation smoke test mints its token only when both `FEDERATION_TEST_AUDIENCE` and `FEDERATION_TEST_TRUST_ACCOUNT` are set. The latter is the staging service account the token acts as, which `tests/test_writes.py` reads as `CI_TRUST_ACCOUNT`. - `tests/stub_api.py`: the trusts route. - It says yes only for exactly `CI_TRUSTED_SUBJECT` on `ci-tests--github-ci`, as source.coop matches a trust exactly. - `ci-tests--says-no-with-200` answers 200 `{"trusted": false}`. - It refuses an assertion made as anyone but the account, though it reads the assertion without verifying its signature. - It also keeps a per-account lookup counter and records who each product was looked up as. ## How to test it - `cargo test`: all suites pass. - `tests/platform.rs`: - audiences kept per issuer; - the table form read like the string; - an issuer without an audience dropped; - unparseable config trusting none; - a JWT read unverified, and non-JWTs (an API key among them) not read; - a verified token's subject, with a missing `exp`, a missing `sub` and an empty `sub` each refused. - `tests/sts.rs`, the account segment: none for a bare name, an empty account or a truncated ARN. - `tests/sts.rs`, the service-account grammar: ids accepted up to 82 characters, and refused for handles, an Ory UUID, the placeholder, uppercase, underscores, one-character halves, a triple hyphen, a second separator, edge hyphens and 83 characters. - `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings` and `cargo check --target wasm32-unknown-unknown`, through the pre-commit hook. - `pytest tests/ --ignore=tests/test_contract.py --ignore=tests/test_federation.py` against `wrangler@4 dev` and the stub, with CI's `.dev.vars`: **42 passed, 20 skipped**. These ran without a real token: - A GitHub-issuer token whose `RoleArn` names no account is refused with `InvalidParameterValue`. - A person handle, an Ory UUID or the placeholder named as the account is refused with the untrusted `AccessDenied`. The token is forged, so this proves no trusts lookup happened. - A forged GitHub token naming a service account is refused as `InvalidIdentityToken`, with the request id and no trusts lookup: the worker fetched GitHub's real JWKS, twice, and found no such key. - A platform token in the URL is refused before any trusts lookup. - A `RoleArn` containing markup and `&` comes back escaped, in a body that parses. - With `PLATFORM_ISSUERS` written as a TOML table in `wrangler.toml`, the platform tests pass as with the string. - With `AUTH_AUDIENCE` blank, a person token gets 501 and a platform token is still verified. - **With a real GitHub token**, in this PR's CI at `dc8b141`: **61 passed, 4 skipped**. Beyond `test_writes.py`'s credentialed tier, which goes through the platform path, these need the token: - A trusted workflow's credentials act as the account: the stub records the account, not the GitHub subject, as the product lookup's subject. - A token file's trailing newline is accepted. - An account that doesn't trust the workflow refuses it with the request id, and a replay within 10 seconds costs no second lookup. A 200 saying no is refused too. - A yes is cached. - On the second worker the token is a person token. It exchanges at `_default`, and the credentials sign a list. A wrong audience and a tampered signature are refused. - **End to end, after deploy**, against staging or this PR's preview, both of which accept staging's audience: give a staging service account a trust for a workflow's subject in source.coop, then run in that workflow: ```yaml permissions: { id-token: write } steps: - run: | curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://data.staging.source.coop" | jq -r .value > "$RUNNER_TEMP/token" - env: AWS_WEB_IDENTITY_TOKEN_FILE: ${{ runner.temp }}/token AWS_ROLE_ARN: arn:aws:iam::<service-account-id>:role/FullAccess AWS_ENDPOINT_URL_STS: https://<proxy>/.sts AWS_ENDPOINT_URL_S3: https://<proxy> AWS_REGION: us-west-2 run: aws s3 ls s3://<owner>/<product>/ ``` Then remove the trust, and within a minute the next exchange reads `AccessDenied … (request id …)`. ## Docs and ADRs - **ADR-009** (platform IdPs): its decision holds (platform issuers, per-issuer audiences, fail closed per issuer), and this implements it. Its migration step 3 and its important note are superseded by ADR-014: platform issuers are not added to `_default`, and a platform token acts only as an account that trusts it. A note there now says so, and its status says implemented in part. ADR-014 (#232) does not list ADR-009 under **Amends**; #232 may want to. - **ADR-004** trusted "exactly one OIDC issuer" and warned that `exp` must be enforced before other issuers are admitted. A note under its trust model now points to platform issuers and ADR-014. Its `exp` warning now says platform tokens must carry `exp` and that the proxy checks it. It also says `alg` is checked before the key fetch, which is not so on the platform path: a forged header still costs one cached key lookup before `verify_token` rejects its algorithm. - **ADR-001** said `source_identity` is "the original OIDC `sub` — the caller's Ory identity". It now says it is the account an API key or a trusted platform token names, which also covers #235's keys. - **ADR-005** says the proxy signs lookups as a caller it has authenticated. The trusts lookup is signed as the account a caller names, before that account is established, and only for that one route. ADR-014 (#232) records this, and `ApiCaller::Account`'s doc now says so. ADR-005's text does not, so it is for #232 or #234 to amend. - **ADR-014** (#232), "the token path is then…", is what this implements, and it still holds. - **docs.source.coop**: no page on `main` covers GitHub Actions against the proxy. The unattended-workflow guide, source-cooperative/docs.source.coop#34, should give the token-file workflow above and the `configure-aws-credentials` caveat until developmentseed/multistore#126. - **source.coop**: `src/lib/services/github-workflow.ts` on `main` hands out `configure-aws-credentials` with `audience: <proxy origin>` and `role/FullAccess`. The audience matches `PLATFORM_ISSUERS` in production and staging, and the Role resolves since #236. The action itself waits on developmentseed/multistore#126. ## PR Checklist - [x] This PR has **no** breaking changes. Person-issuer tokens and API keys behave as before; GitHub tokens, refused until now as an untrusted issuer, take the new path. An empty `AUTH_AUDIENCE` no longer disables API-key exchange. - [x] I have updated or added new tests to cover the changes in this PR. - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop): it calls the trusts route from source-cooperative/source.coop#566, already on `main`. ## Related Issues Closes #222. Part of #223: this is the proxy side, and #223's "done when", a workflow in an unrelated repository writing with only its ambient token, also needs a deployment and, for the action source.coop hands out, developmentseed/multistore#126. Builds on #235 and #236, both merged. Includes #246 and #247. ADRs: #232 (ADR-014). Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153, developmentseed/multistore#160. Epic: source-cooperative/source.coop#491. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On
main, now that #235 is merged; two commits. How to test it lists what I ran locally.What I'm changing
/.stsserves three hardcoded Roles instead of one, for ID tokens and API keys alike, as ADR-014 (#232) specifies:FullAccessReadOnly_defaultFullAccessdoes; kept because deployed clients name itrole/<name>resource of an ARN of any partition and account (arn:aws:iam::000000000000:role/ReadOnly), because SDKs check the ARN shape before sending. A pathed resource (role/team/ReadOnly), another case (readonly) or any other name isRoleNotFound, never a fallback to a default.ReadOnlyseals one scope into the session token'sallowed_scopes: every product (*), read actions only (GetObject,HeadObject,ListBucket). The gateway never calls multistore's own scope check (auth::authorize), because this proxy's registry is the authorizer, so the registry enforces it.get_bucketchecks the ceiling first, before any Source API lookup, and refuses with the sameAccessDeniedas every other refusal (ADR-011, Denial Semantics). An INFO log line is the only record of why.FullAccessand_defaultseal no scopes, as_defaultnever has, so every session already issued keeps working. A_defaultexchange returns the same response as before,AssumedRoleIdincluded.StsCredentialRegistry::get_role) and the API-key exchange (exchange_api_key, which accepted only_default) share one lookup,sts::role. The key exchange's log line now names the Role.source.coop
main's GitHub integration snippet (src/lib/services/github-workflow.ts) already hands outarn:aws:iam::<service-account-id>:role/FullAccess. This PR makes that Role name resolve. The rest of that path is the next PR in this stack, which trusts a GitHub token for the account it names (#222, #223), plusGetCallerIdentityin developmentseed/multistore#126.Decisions to flag
ReadOnlycannot be enforced until multistore plumbs the assumed Role through to the registry. That isn't needed: what is sealed is the Role's ceiling, not its name, andAuthenticatedIdentity.allowed_scopesalready reachesBucketRegistry::get_bucket(multistore 0.7.2,auth/identity.rs). No multistore change.*) with no prefix. A scope naming one product or a prefix permits nothing rather than being half-interpreted. ADR-011's resource matching can arrive with account-owned Roles. The*sentinel means something only to this proxy, because multistore's exact-matchauthorizenever runs here.authorize, where empty means deny-all, as ADR-001 noted. It is safe because only this proxy mints session tokens, sealed withSESSION_TOKEN_KEY, and every_defaultsession carries empty scopes.ReadOnlyexcludesGetObjectVersion(reading a versioned copy source). The write gate already classes it as a write, and a native test pins that the ceiling andis_write_actionagree on every action. That is the divergence ADR-011 warns about.role/<name>resource. The old_defaultcheck matched only thearn:prefix and the:role/_defaultsuffix, so it also accepted malformed strings such asarn:role/_default. Those are now refused, and no SDK sends them anyway: they fail the SDKs' own 20-character minimum.How I did it
src/sts.rs:role(role_arn, issuer, audiences, max_session)returns the named Role orNone, replacingdefault_roleandis_default_role.ALL_PRODUCTSis the*sentinel.src/authz.rs:ceiling_permits(scopes, action).src/source_api/registry.rs: the check at the top ofget_bucket.src/lib.rs: the key exchange resolves the named Role before its lookup, as before, and mints under it.README.md: a Roles section.adrs/001,004and011, in a separate commit: see below.How to test it
cargo test: all suites pass.tests/sts.rscovers bare and ARN names for all three Roles, including a service-account-shaped account segment, and refusal of unknown, lower-case, pathed, suffixed and non-role names.tests/authz.rscovers thatReadOnly's sealed ceiling allows exactly the actionsis_write_actioncalls reads, thatFullAccessand_defaultallow every action, and that narrower scopes permit nothing.tests/keys.rschecks that aReadOnlykey's session unseals withReadOnly's ceiling.cargo fmt --check,cargo clippy --target wasm32-unknown-unknown -- -D warningsandcargo check --target wasm32-unknown-unknown, through the pre-commit hook.pytest tests/ --ignore=tests/test_contract.pyagainstwrangler dev(wrangler 3.114) andtests/stub_api.py: 35 passed, 15 skipped. The skipped ones are the credentialed tests that need CI's GitHub token. The newtest_read_only_refuses_a_write_before_anything_is_looked_upexchanges the stub's live key forReadOnlyand forFullAccess, then writes to a product the stub has never heard of.ReadOnlygetsAccessDenied.FullAccessgets past the ceiling to the product lookup and getsNoSuchBucket, and so does aReadOnlyread. The upstream write itself fails closed in CI, so the product lookup is where the ceiling's path can be told apart. I also disabled the check, saw this test fail (ReadOnlygotNoSuchBucket), and restored it.Docs and ADRs
_default". A note under that section now points to ADR-014 and this PR, and ADR-004's "Implemented by" line lists this PR.assumed_role_idis "currently always_default" and that the sealedallowed_scopesis empty and "not enforced on this path". Its credential table now names the three Roles and their ceilings. Its bullet now says the registry enforces the ceiling and reads empty as no ceiling.FullAccessandReadOnly, with_defaultkept as an alias (Add the ReadOnly Role alongside FullAccess #221)". This PR implements that, so it still holds. ADR-010's scope note is ADR-014's amendment and also holds.maindescribes/.stsor its Roles yet. The unattended-workflow guide, Unattended workflow guide docs.source.coop#34, is where the Role names belong.PR Checklist
_defaultbehaves and answers as before; only malformed ARNs that no SDK can send are newly refused.main's GitHub snippet namesrole/FullAccess, which resolves from this PR on. No source.coop change is needed.Related Issues
Closes #221. Builds on #235, merged. ADRs: #232 (ADR-014), #234 (ADR-013 revised). Epic: source-cooperative/source.coop#491.
🤖 Generated with Claude Code
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd