Skip to content

docs: new architecture RFC - #115

Open
alukach wants to merge 20 commits into
mainfrom
docs/new-architecture-rfc
Open

docs: new architecture RFC#115
alukach wants to merge 20 commits into
mainfrom
docs/new-architecture-rfc

Conversation

@alukach

@alukach alukach commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

What I'm changing

This PR adds a new RFP and accompanying ADRs to the codebase.

How I did it

This RFC (adrs/rfc-001.md) describes a new architecture for our data proxy; some of which has been partially actualized in #109. Accompanying the RFC is various ADRs that go into greater detail about components of the architecture. The new architecture is future facing (ie it includes designs for new features) but attempts to be constrained, only thoroughly exploring a first wave of near-term features and only acknowledging later-stage features like metering and rate-limiting.

All nine documents are written in ASD-STE100 Simplified Technical English: active voice, simple present tense, one topic per sentence, approved vocabulary (technical names and technical verbs excepted), and procedures as numbered vertical lists. The goal is that the design reads unambiguously, including for reviewers who do not read English as a first language.

How to test it

Review, comment inline on PR.

@alukach alukach changed the title [IN PROGRESS] docs: new architecture RFC docs: new architecture RFC Mar 23, 2026
@alukach
alukach marked this pull request as ready for review March 23, 2026 22:39
alukach added a commit that referenced this pull request Mar 24, 2026
…116)

## What I'm changing

Pushed by a recent spike of high ALB egress bills, this PR swaps out the
data proxy for a data proxy written with
[multistore](https://github.com/developmentseed/multistore). This allows
us to deploy the data proxy onto Cloudflare Workers, thereby pushing all
egress charges directly to S3 and in line with the AWS Open Data
Program.

This is a read-only proxy, write operations will be added at a later
date.

## How I did it

Deployed to Cloudflare Workers. We're currently serving ~4M requests per
day and are seeing an error rate of ~0.001%

### Custom URLs

Obtaining custom URLs without migrating all of the source.coop DNS
settings over the Cloudflare was a bit of a challenge. I opted to host
the proxy workers on `coolnewgeo.com` (an unused domain owned by Radiant
Earth):

* `data.coolnewgeo.com` - prod
* `staging.data.coolnewgeo.com` - staging

Custom Hostnames have been set up under `coolnewgeo.com` for
`data.source.coop` and `data.staging.source.coop`, both pointing to a
null fallback origin of `fallback.coolnewgeo.com` (which has a DNS A
record pointing to `192.0.2.1`).

Configured routes on the workers connect these custom hostnames to each
worker environment:


https://github.com/source-cooperative/data.source.coop/blob/0a44d6bd70d6f132f0c519f0d9367d82ed79a2dd/wrangler.toml#L5-L11


https://github.com/source-cooperative/data.source.coop/blob/0a44d6bd70d6f132f0c519f0d9367d82ed79a2dd/wrangler.toml#L36-L40

## How to test it

This has been running in production for the past week and is used by
https://source.coop.

## PR Checklist

- [ ] ~This PR has **no** breaking changes.~
- [ ] I have updated or added new tests to cover the changes in this PR.
- [ ] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop),
      and I have opened issue/PR #XXX to track the change.

## Related Issues

* #115
* #1 

## TODO

- [x] Setup autodeploy for staging on merges to `main`
- [x] Setup autodeploy for production on releases
- [x] Setup autodeploy for PRs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
alukach and others added 5 commits March 23, 2026 19:53
Covers federated identity via OIDC, user-defined Roles and IdPs,
claim constraint language, permission model, credential issuance,
request-time authorization, and client tooling integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… token exchange

ADR-001: Replace embedded SecretAccessKey with HMAC derivation, add ES256
signing, revocation via jti deny-list, and updated SessionToken JWT structure.

ADR-004: Rewrite for two-tier IdP model (platform + account-registered),
user-defined Roles with claim constraints and permission statements,
AWS STS-compatible request/response format, and removal of SC Credential
Tokens.

ADR-005: Replace fixed 3-role model with user-defined Roles as permission
ceiling. Resolve grant schema with concrete permission statement format
(read/write actions, URN resource patterns with prefix scoping). Update
authorization flow to use Role ceiling from SessionToken intersected with
dynamic account permissions.

RFC-001: Update sections 4, 7, 8, 13, and 14 to reflect new design. Mark
open question 7 (grant schema) as resolved. Add new open questions for
org permission model, HMAC secret rotation, and multipart upload credential
expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alukach
alukach force-pushed the docs/new-architecture-rfc branch from 600e47a to 2fd17f2 Compare March 24, 2026 02:53
@github-actions

github-actions Bot commented Mar 24, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-115.source-coop.workers.dev

  • Date: 2026-07-30T18:59:27Z
  • Commit: 4adf56b

Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/rfc-001.md

The Workers deployment hosts an STS endpoint at `/.sts` for credential exchange.

```mermaid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to visualize both the current (non-Cloudflare) data flow vs. the new Cloudflare data flow.

Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/rfc-001.md Outdated
Comment thread adrs/006-outbound-storage.md Outdated
Comment thread adrs/007-middleware.md Outdated
Comment thread adrs/008-crate-architecture.md Outdated
Comment thread adrs/008-crate-architecture.md Outdated
Comment thread adrs/002-runtimes.md Outdated
**Costs / Risks**

- WASM compilation constrains library choices (no `std` features that don't work in WASM)
- In-region, high-throughput workflows (e.g. bulk ETL in `us-west-2`) route through the edge rather than staying within the region — this adds latency and may incur upstream egress fees that an in-region proxy would avoid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How significant is the added latency?

Comment thread adrs/004-sts.md Outdated
Comment thread adrs/004-sts.md Outdated
Comment on lines +215 to +223
1. Parse `RoleArn` → extract `account_id` and `role_name`
2. Load Role definition from policy store (cached, 30–60s TTL)
3. Extract `iss` from JWT (without verification)
4. Match `iss` against the Role's allowed IdPs — reject immediately if no match
5. Fetch JWKS from the matched IdP (cached, 1hr TTL, 3s timeout, stale-while-revalidate on fetch failure)
6. Verify JWT signature, `exp`, `nbf` (60s clock skew tolerance), and `aud`
7. Evaluate claim constraints for the matched IdP binding
8. Validate `DurationSeconds` ≤ Role's `max_session_duration`
9. Generate credentials (see ADR-001 for token structure) and return response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1. Parse `RoleArn` → extract `account_id` and `role_name`
2. Load Role definition from policy store (cached, 30–60s TTL)
3. Extract `iss` from JWT (without verification)
4. Match `iss` against the Role's allowed IdPs — reject immediately if no match
5. Fetch JWKS from the matched IdP (cached, 1hr TTL, 3s timeout, stale-while-revalidate on fetch failure)
6. Verify JWT signature, `exp`, `nbf` (60s clock skew tolerance), and `aud`
7. Evaluate claim constraints for the matched IdP binding
8. Validate `DurationSeconds` ≤ Role's `max_session_duration`
9. Generate credentials (see ADR-001 for token structure) and return response
1. Parse `RoleArn` → extract `account_id` and `role_name`
2. Load Role definition from policy store (cached, 30–60s TTL)
3. Extract `iss` from JWT (without verification)
4. Match `iss` against the Role's allowed IdPs — reject immediately if no match
5. Fetch JWKS from the matched IdP (cached, 1hr TTL, 3s timeout, stale-while-revalidate on fetch failure)
6. Verify JWT signature, `exp`, `nbf` (60s clock skew tolerance), and `aud`
7. Evaluate claim constraints for the matched IdP binding
8. Validate `DurationSeconds` ≤ Role's `max_session_duration`
9. Generate credentials (see ADR-001 for token structure) and return response
````mermaid
flowchart TD
Start["Receive AssumeRoleWithWebIdentity request"] --> Parse["1. Parse RoleArn<br/>→ account_id + role_name"]
Parse --> LoadRole["2. Load Role from policy store<br/>(cached, 30–60s TTL)"]
LoadRole --> ExtractIss["3. Extract iss from JWT<br/>(without verification)"]
ExtractIss --> MatchIdP{"4. Does iss match<br/>Role's allowed IdPs?"}
MatchIdP -- No --> RejectIdP["Reject:<br/>IDPRejectedClaim"]
MatchIdP -- Yes --> FetchJWKS["5. Fetch JWKS from IdP<br/>(cached 1hr TTL, 3s timeout,<br/>stale-while-revalidate)"]
FetchJWKS --> VerifyJWT{"6. Verify JWT<br/>signature, exp, nbf, aud"}
VerifyJWT -- Invalid --> RejectJWT["Reject:<br/>InvalidIdentityToken"]
VerifyJWT -- Valid --> EvalClaims{"7. Evaluate claim<br/>constraints"}
EvalClaims -- Fail --> RejectClaims["Reject:<br/>IDPRejectedClaim"]
EvalClaims -- Pass --> ValidateDuration{"8. DurationSeconds ≤<br/>max_session_duration?"}
ValidateDuration -- No --> RejectDuration["Reject:<br/>ValidationError"]
ValidateDuration -- Yes --> GenCreds["9. Generate short-lived<br/>SigV4 credentials"]
GenCreds --> Return["Return credentials response"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the mermaid diagram is worth the extra space, but I do find it easier to understand the decision flow.

Comment thread adrs/004-sts.md
"failure_reason": null
}
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the STS log entries may include PII (e.g. assumed_by and client_ip), and that a future logging ADR will need to address retention/redaction policies.

Comment thread adrs/006-outbound-storage.md Outdated

Data providers register their upstream storage (their own S3 bucket, GCS bucket, etc.) with Source Cooperative. The proxy serves as an access control, metering, and distribution layer in front of their data.

Data providers get:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the current version of object_store compile to WASM? Or is the risk that future versions of object_store may not be compatible?

Co-authored-by: Tyler Erickson <tylerickson@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
## What I'm changing

<!-- Describe the high-level goals of the change -->

## How I did it

<!-- Discuss the implementation strategy and considerations made -->

## How to test it

<!-- Inform the reviewer how they can validate that these changes work
-->

## PR Checklist

- [ ] This PR has **no** breaking changes.
- [ ] I have updated or added new tests to cover the changes in this PR.
- [ ] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop),
      and I have opened issue/PR #XXX to track the change.

## Related Issues

<!-- Reference any existing related GitHub Issues -->
alukach added a commit that referenced this pull request Jul 19, 2026
Capture the #188/#189 design as adrs/009-edge-caching.md, following the ADR
convention introduced in #115 (adrs/NNN-title.md): context + constraints, the
ranged-only/range-sliced/streamed decision, the eviction & overwrite-consistency
model (ETag-in-key rotation, If-Match, 60s meta-TTL staleness bound), rollout,
consequences, alternatives, and future options (per-product opt-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-006 described the OIDC issuer model as producing "audience-scoped
JWTs" without saying what the audience or subject actually are, and
listed provider credential isolation as unresolved. A security review of
the current implementation showed that gap is load-bearing: the claims
are the only channel through which an upstream cloud can express which
data connection may use a given role.

Specifies:

- `sub` = `scv1:conn:{connection_id}`, a stable per-connection identity,
  kept exactly matchable because Azure federated identity credentials
  match `subject` exactly with no wildcards.
- `aud` = per-connection for direct federation. Every cloud validates
  the audience against the provider registration before evaluating any
  trust policy, so a provider registering only their own connection's
  audience is protected even with an unconditioned trust policy. This
  makes the safe configuration the default instead of something the
  provider must remember to add.
- Brokered access cannot use audience to discriminate (one OIDC provider
  per issuer URL per account must accept every platform audience), so
  its trust policy constrains `sub` as an allowlist. Negated conditions
  are called out as unsafe: an absent context key makes `StringNotLike`
  evaluate true.
- Cache keying invariants: key on `(role, sub)` rather than the role
  alone, and add a session-policy fingerprint in the same change if
  per-request scoping is ever introduced, or requests differing only by
  prefix collide.

Also records why scope cannot live in custom claims (AWS exposes only
amr/aud/email/oaud/sub for a generic OIDC provider) and why it should
not be suffixed onto the customer-facing `sub`.

This is the outbound proxy-to-cloud contract; it is independent of the
inbound `sub` = `account_id` contract in ADR-005.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 3m 25s —— View job


❌ Changes requested — see findings below.

  • adrs/001-s3-credentials.md:82-91 — SigV4 verification never binds the access_key_id claim inside the SessionToken JWT back to the AccessKeyId actually used in the request. The design relies on two independently-obtained secrets: SecretAccessKey = HMAC(server_secret, AccessKeyId) and the ES256-signed SessionToken. The doc claims a leaked SessionToken alone "gives no access without the related SecretAccessKey" (line 73, line 77), but that's only true if the verifier checks that the token's own access_key_id claim (line 59) matches the AccessKeyId from the Authorization header. As written, the 6-step verification flow never performs that comparison — it independently validates the SigV4 signature (steps 1-4) and the JWT (step 5), then authorizes using "the identity and permissions from the token" (step 6). Any caller that legitimately obtains its own low-privilege (AccessKeyId, SecretAccessKey) pair from /.sts could attach a different, higher-privilege SessionToken it intercepted (the doc explicitly says the token's permissions claim is readable by anyone who intercepts it) in the X-Amz-Security-Token header, sign the request with its own valid secret, and pass all six steps as documented. This same gap is repeated in adrs/005-authorization.md:52-58 ("Step 1 — Identify the caller").

    Suggested fix — add an explicit binding check to the flow:

     5. Read the `SessionToken` JWT from the `X-Amz-Security-Token` header. Then check the ES256 signature, `exp`, `nbf` (with a clock difference of 60 seconds), and `aud`.
    -6. Continue to the authorization (refer to ADR-005) with the identity and the permissions from the token.
    +6. Compare the `access_key_id` claim of the SessionToken with the `AccessKeyId` from the Authorization header. Refuse the request if they do not agree.
    +7. Continue to the authorization (refer to ADR-005) with the identity and the permissions from the token.
  • adrs/004-sts.md:195,212 and adrs/008-api-keys.md:93 — no validation rule is specified for the caller-supplied RoleSessionName, even though it is echoed verbatim into the STS XML response (AssumedRoleId, e.g. line 212) and into structured JSON logs (session_name, e.g. adrs/004-sts.md:285). ADR-004 §"Validation of a New Role" (line 162) is careful to constrain the Role name to [a-z0-9][a-z0-9-]{0,62}, but no equivalent constraint is given for RoleSessionName. Real AWS STS restricts RoleSessionName to a fixed character set ([\w+=,.@-]*, ≤64 chars) precisely to keep it safe to embed in ARNs, XML responses, and CloudTrail logs. Without a similar constraint here, an implementation that naively string-interpolates RoleSessionName into the XML response is exposed to XML/markup injection from a value that is entirely attacker-controlled at request time.

    Suggested fix — add a validation rule alongside the Role name rule, e.g.: "RoleSessionName must match [\w+=,.@-]{2,64}; the proxy rejects any request whose value does not match before it is used in a response or a log entry."

Everything else — the STS/IdP trust model, the Role-ceiling ∩ account-permission authorization design, the outbound aud/sub token contract in adrs/006-outbound-storage.md, and the cache-key invariants — is internally consistent and the accepted trade-offs are clearly reasoned.

…lish

Rewrite all nine architecture documents against the ASD-STE100 writing
rules so that the design is easier to read for reviewers who do not read
English as a first language, and so that each requirement reads
unambiguously.

Applied throughout:

- Active voice and simple present tense.
- One topic per sentence, within the STE length limits.
- Approved vocabulary, with technical names and technical verbs kept.
- No gerund phrases; procedures written as numbered vertical lists.
- Consistent terminology for each concept (Role, ceiling, policy store).
- Em-dash asides split into separate sentences.

The technical content is unchanged. Every table, code block, mermaid
diagram, footnote, link, and heading anchor is preserved. The only
substantive edit is the Decision Index in RFC-001, which now lists
ADR-008; that ADR exists in the branch but was missing from the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants