Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,31 @@ jobs:

- name: Run integration tests
run: uvx --with pytest,boto3,requests pytest tests/integration/ -v

live-federation:
name: Live Backend Federation (real AWS STS)
runs-on: ubuntu-latest
# Always runs and **fails** when the live AWS target is unconfigured (the
# test panics on missing env), so green CI genuinely means the real AWS STS
# path was exercised — it can't be silently skipped. To make it pass, set
# repository Actions variables MULTISTORE_TEST_ROLE_ARN / MULTISTORE_TEST_BUCKET
# / MULTISTORE_TEST_REGION and grant the role's trust policy this repo's
# GitHub OIDC subject (audience sts.amazonaws.com).
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Run live federation test
env:
MULTISTORE_TEST_ROLE_ARN: ${{ vars.MULTISTORE_TEST_ROLE_ARN }}
MULTISTORE_TEST_BUCKET: ${{ vars.MULTISTORE_TEST_BUCKET }}
MULTISTORE_TEST_REGION: ${{ vars.MULTISTORE_TEST_REGION }}
MULTISTORE_TEST_KEY: ${{ vars.MULTISTORE_TEST_KEY }}
# `--ignored` runs the otherwise-ignored live test. It fails the job if
# the required variables above are not set.
run: cargo test -p multistore-backend-federation --test live_sts -- --ignored --nocapture
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ jobs:
id-token: write
env:
DEPLOY_URL: ${{ needs.deploy.outputs.deploy_url }}
# Object key the backend-federation smoke test GETs from the private
# bucket (defaults to hello.txt). The full federation path is validated
# here on both preview (PR) and staging deploys, against the stable
# staging OIDC issuer — see preview.yml / staging.yml.
FEDERATION_TEST_KEY: ${{ vars.FEDERATION_TEST_KEY }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ jobs:
worker_name: multistore-proxy-pr-${{ github.event.pull_request.number }}
wrangler_config: wrangler.deploy.toml
environment: preview
oidc_issuer_override: "https://multistore-proxy-pr-${{ github.event.pull_request.number }}.${{ vars.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev"
# Mint tokens with the STABLE staging issuer rather than this PR's own URL.
# All deployments share OIDC_PROVIDER_KEY, so a preview can sign assertions
# whose `iss` is the staging URL; AWS then validates them against the
# single, already-registered staging IAM OIDC provider. This lets the full
# backend-federation path (tests/smoke/test_federation.py) run on every PR
# WITHOUT creating/tearing down a per-PR identity provider + role.
oidc_issuer_override: ${{ vars.STAGING_OIDC_ISSUER }}
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ jobs:
worker_name: multistore-staging
wrangler_config: wrangler.deploy.toml
environment: staging
# The canonical issuer the AWS IAM OIDC provider is registered against.
# Staging serves JWKS at this URL; PR previews reuse the same value (see
# preview.yml) so one provider + role covers both — no per-PR setup.
oidc_issuer_override: ${{ vars.STAGING_OIDC_ISSUER }}
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"crates/static-config",
"crates/sts",
"crates/oidc-provider",
"crates/backend-federation",
"examples/server",
"examples/lambda",
"examples/cf-workers",
Expand All @@ -18,6 +19,7 @@ default-members = [
"crates/static-config",
"crates/sts",
"crates/oidc-provider",
"crates/backend-federation",
"examples/server",
"examples/lambda",
]
Expand Down Expand Up @@ -108,3 +110,4 @@ multistore-metering = { path = "crates/metering", version = "0.4.0" }
multistore-cf-workers = { path = "crates/cf-workers", version = "0.4.0" }
multistore-oidc-provider = { path = "crates/oidc-provider", version = "0.4.0" }
multistore-path-mapping = { path = "crates/path-mapping", version = "0.4.0" }
multistore-backend-federation = { path = "crates/backend-federation", version = "0.4.0" }
23 changes: 23 additions & 0 deletions crates/backend-federation/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "multistore-backend-federation"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Outbound credential federation (OIDC identity -> backend cloud STS) for the multistore S3 proxy gateway"

[dependencies]
multistore.workspace = true
chrono.workspace = true
serde.workspace = true
quick-xml.workspace = true
url.workspace = true
thiserror.workspace = true

[dev-dependencies]
# Live, network-touching functional test (`tests/live_sts.rs`) — gated on env
# vars, so it self-skips during ordinary `cargo test`.
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
reqwest.workspace = true
object_store.workspace = true
serde_json.workspace = true
futures.workspace = true
47 changes: 47 additions & 0 deletions crates/backend-federation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# multistore-backend-federation

Outbound credential federation for the [`multistore`](https://crates.io/crates/multistore) S3 proxy gateway. The runtime-agnostic *client* side of AWS STS `AssumeRoleWithWebIdentity`: the proxy presents its own OIDC identity to a **backend cloud**, assumes a role there, and signs backend requests with the temporary credentials — so the operator never holds long-lived backend keys.

It is the symmetric counterpart to [`multistore-sts`](../sts), which is the inbound `AssumeRoleWithWebIdentity` **server** (minting proxy credentials for callers).

```
inbound outbound
caller ──OIDC──▶ multistore-sts multistore-backend-federation ──OIDC──▶ backend cloud STS
◀─proxy creds─ (server: mint) (client: build req / parse resp) ◀─backend creds─
```

## How It Works

```
proxy's OIDC identity (multistore-oidc-provider mints + signs the JWT)
│ self-signed JWT (web identity token)
┌──────────────────────────────────────┐
│ multistore-backend-federation │
│ │
│ 1. build AssumeRoleWithWebIdentity │ ← request URL + form body / pairs
│ request (this crate) │
│ 2. caller POSTs it to backend STS │ ← transport owned by the caller
│ 3. parse_response(xml) │ ← typed creds or typed FederationError::Sts
│ 4. FederatedCredentials::apply_to │ ← inject into BucketConfig.backend_options
└──────────────────────────────────────┘
│ temporary backend AccessKeyId + SecretAccessKey + SessionToken
multistore S3 backend signs requests to the private bucket
```

This crate is **mechanism only**: it owns the STS request/response shapes and the `BucketConfig` injection. It does *not* mint the JWT, perform HTTP, cache, or wire middleware — that orchestration lives in [`multistore-oidc-provider`](../oidc-provider), which delegates its AWS exchange to this crate.

## Relationship to the other auth crates

| crate | direction | role |
|---|---|---|
| `multistore-sts` | inbound | server: validate caller OIDC token, mint `TemporaryCredentials` |
| `multistore-oidc-provider` | outbound | mint the proxy's own JWT (sign/JWKS/discovery) + cache + middleware |
| `multistore-backend-federation` | outbound | client: build/parse backend STS exchange, inject `FederatedCredentials` |

## Bring your own token

Because the crate only depends on `multistore` (core) and a few wire libraries — no RSA/JWKS machinery — a caller that already holds a web-identity token (an external IdP, a workload-identity assertion, a pre-minted JWT) can use it standalone to exchange that token for backend credentials, without pulling in the full OIDC provider.
Loading
Loading