Skip to content
Closed
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
29 changes: 24 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,19 @@ jobs:
# 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. No
# person-issuer token exists in CI; AUTH_ISSUER names an issuer that
# mints nothing, and AUTH_AUDIENCE keeps /.sts from answering 501.
# 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://auth.example.invalid"
echo "AUTH_AUDIENCE=source-data-proxy-ci"
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
Expand Down Expand Up @@ -148,6 +151,10 @@ jobs:
fi
echo "::add-mask::$token"
echo "CI_WRITE_ID_TOKEN=$token" >> "$GITHUB_ENV"
# 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).
Expand Down Expand Up @@ -175,8 +182,15 @@ 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.
wrangler dev --port 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 @@ -186,6 +200,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 @@ -201,6 +217,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
7 changes: 4 additions & 3 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ jobs:
# 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 then no token is minted and the copy-source authz
# test skips; the rest of the suite is unaffected.
if: vars.FEDERATION_TEST_AUDIENCE != ''
# 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 Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ 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` | 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 = `/.sts` token exchange is disabled (returns 501) |
| `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"]}`. 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 |
Expand Down
13 changes: 10 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,23 @@ fn build_config(env: &Env) -> AppConfig {
if auth_audiences.is_empty() {
// Fail closed: without an audience restriction, an ID token minted for
// ANY OAuth client of AUTH_ISSUER could be exchanged for a user's
// credentials, so /.sts is disabled entirely (returns 501) until set.
tracing::warn!("AUTH_AUDIENCE not set: /.sts token exchange is disabled (returns 501)");
// credentials, so its exchange is disabled (returns 501) until set.
tracing::warn!(
"AUTH_AUDIENCE not set: person-token exchange at /.sts is disabled (returns 501)"
);
}

// Platform identity providers (GitHub Actions, say), each with its own
// audiences. Unset trusts none.
let platform_issuers = env
let mut platform_issuers = env
.var("PLATFORM_ISSUERS")
.map(|v| crate::platform::parse_issuers(&v.to_string()))
.unwrap_or_default();
// The platform path claims its issuers' tokens ahead of the STS route, so
// an entry for the person issuer would refuse every person exchange.
if platform_issuers.remove(&auth_issuer).is_some() {
tracing::error!(issuer = %auth_issuer, "PLATFORM_ISSUERS names AUTH_ISSUER; ignoring that entry");
}

// Ceiling for client-requested DurationSeconds on /.sts. Unset → 3600 (1h),
// matching multistore's own default so behavior is unchanged until raised.
Expand Down
55 changes: 32 additions & 23 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,23 +171,6 @@ async fn fetch(req: web_sys::Request, env: Env, ctx: Context) -> Result<web_sys:
// writable and signable) and the backend-auth middleware signs them. See
// `authz` and `backend_auth`.

// ── Short-circuit: STS disabled (fail closed) ───────────────────
// `/.sts` requires an audience restriction (AUTH_AUDIENCE) to be safe —
// without it, an ID token minted for any OAuth client of AUTH_ISSUER could
// be exchanged for a user's credentials. When unset, refuse the endpoint
// with a 501 rather than serving it unrestricted.
if parts.path == "/.sts" && config.auth_audiences.is_empty() {
let resp = ErrorResponse {
code: "NotImplemented".to_string(),
message: "STS token exchange is not configured".to_string(),
resource: String::new(),
request_id: request_id.clone(),
};
return Ok(add_cors(
GatewayResponse::Response(ProxyResult::xml(501, resp.to_xml())).into_web_sys(),
));
}

// ── Short-circuit: write to a keyless path ──────────────────────
// A keyless PUT/DELETE (e.g. `aws s3 cp f s3://account/product` with no
// trailing slash) targets the product root, which has no object key.
Expand Down Expand Up @@ -253,6 +236,24 @@ async fn fetch(req: web_sys::Request, env: Env, ctx: Context) -> Result<web_sys:
}
}

// ── Short-circuit: STS disabled (fail closed) ───────────────────
// The person issuer's route requires an audience restriction
// (AUTH_AUDIENCE) to be safe — without it, an ID token minted for any
// OAuth client of AUTH_ISSUER could be exchanged for a user's credentials.
// When unset, refuse it with a 501 rather than serving it unrestricted.
// API keys and platform tokens, answered above, do not depend on it.
if parts.path == "/.sts" && config.auth_audiences.is_empty() {
let resp = ErrorResponse {
code: "NotImplemented".to_string(),
message: "STS token exchange is not configured".to_string(),
resource: String::new(),
request_id: request_id.clone(),
};
return Ok(add_cors(
GatewayResponse::Response(ProxyResult::xml(501, resp.to_xml())).into_web_sys(),
));
}

// ── Build gateway with route handlers ──────────────────────────
let registry = SourceCoopRegistry::new(
config.api_base_url.clone(),
Expand Down Expand Up @@ -551,17 +552,22 @@ async fn api_key_exchange(
// API being unreachable, which fails closed as a 500 the SDK retries.
Err(e) => {
tracing::warn!(%request_id, error = %e, "API key exchange failed");
build_sts_error_response(&e)
sts_refusal(&e, request_id)
}
},
)
}

/// `InvalidIdentityToken`, with the request id in the message.
fn key_refusal(message: &str, request_id: &str) -> (u16, String) {
build_sts_error_response(&ProxyError::InvalidOidcToken(with_request_id(
message, request_id,
)))
sts_refusal(&ProxyError::InvalidOidcToken(message.into()), request_id)
}

/// `build_sts_error_response`, with the request id in the message.
fn sts_refusal(e: &ProxyError, request_id: &str) -> (u16, String) {
let (status, xml) = build_sts_error_response(e);
let message_end = format!("{}</Message>", with_request_id("", request_id));
(status, xml.replacen("</Message>", &message_end, 1))
}

/// `message` with the request id, if there is one: SDKs show a user the
Expand Down Expand Up @@ -688,9 +694,12 @@ async fn platform_exchange(
api_auth: &ApiAuth,
request_id: &str,
) -> Option<(u16, String)> {
let sts = try_parse_sts_request(parts.query.as_deref())
let mut sts = try_parse_sts_request(parts.query.as_deref())
.or_else(|| try_parse_sts_request(parts.form_body.as_deref()))?
.ok()?;
// SDKs send a token file's contents as-is, and `jq -r … > file` ends it in
// a newline, which the signature segment's base64 decode rejects.
sts.web_identity_token = sts.web_identity_token.trim().to_string();
let (header, claims) = platform::unverified(&sts.web_identity_token)?;
let issuer = claims.get("iss")?.as_str()?;
let audiences = config.platform_issuers.get(issuer)?;
Expand Down Expand Up @@ -740,7 +749,7 @@ async fn exchange_platform_token(
} = token;
let failed = |e: ProxyError| {
tracing::warn!(%request_id, %issuer, error = %e, "platform token exchange failed");
build_sts_error_response(&e)
sts_refusal(&e, request_id)
};
let not_authorized = || {
let message = "Not authorized to perform sts:AssumeRoleWithWebIdentity";
Expand Down
34 changes: 16 additions & 18 deletions src/source_api/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,9 @@ const KEY_STANDING_CACHE_SECS: u32 = 60; // 1 minute
/// reason: a trust that is removed should stop minting quickly (ADR-014).
const TRUST_CACHE_SECS: u32 = 60; // 1 minute

/// A refusal from the trusts route, cached under its own key: long enough that
/// replaying one token its account does not trust costs about one lookup per
/// 10 seconds, however many addresses it comes from, and short enough that a
/// trust just added works within seconds.
/// A refusal from the trusts route: long enough that replaying one token its
/// account does not trust costs about one lookup per 10 seconds per data
/// center, and short enough that a trust just added works within seconds.
const REFUSED_TRUST_CACHE_SECS: u32 = 10;

// ── Public cache functions ─────────────────────────────────────────
Expand Down Expand Up @@ -220,7 +219,7 @@ pub async fn get_or_fetch_key_standing(
/// assume-role call. Asked as the account itself. The route says yes with a
/// 200, cached for `TRUST_CACHE_SECS` like every 200, and no with a 403 (a 401
/// for an account it cannot resolve), which `cached_fetch` leaves uncached and
/// this caches for `REFUSED_TRUST_CACHE_SECS` under a key of its own.
/// this caches as `{"trusted":false}` for `REFUSED_TRUST_CACHE_SECS`.
pub async fn get_or_fetch_trust(
api_base_url: &str,
account: &str,
Expand All @@ -241,11 +240,6 @@ pub async fn get_or_fetch_trust(
utf8_percent_encode(issuer, PATH_SEGMENT),
utf8_percent_encode(subject, PATH_SEGMENT),
);
let refused_key = format!("{cache_key}&refused");
let cache = worker::Cache::default();
if matches!(cache.get(&refused_key, false).await, Ok(Some(_))) {
return Err(ProxyError::AccessDenied);
}
let body = serde_json::json!({ "issuer": issuer, "subject": subject }).to_string();
let answer = cached_fetch::<TrustAnswer>(
&cache_key,
Expand All @@ -260,17 +254,21 @@ pub async fn get_or_fetch_trust(
.await;
let trusted = match answer {
Ok(answer) => answer.trusted,
Err(ProxyError::AccessDenied) => false,
Err(ProxyError::AccessDenied) => {
let cache = worker::Cache::default();
let refused = r#"{"trusted":false}"#;
cache_put(&cache, &cache_key, refused, REFUSED_TRUST_CACHE_SECS).await;
false
}
Err(e) => return Err(e),
};
if !trusted {
// A 200 saying no was cached like any 200: drop it, so a no is held
// for `REFUSED_TRUST_CACHE_SECS` whichever way the route said it.
let _ = cache.delete(cache_key.as_str(), false).await;
cache_put(&cache, &refused_key, "{}", REFUSED_TRUST_CACHE_SECS).await;
return Err(ProxyError::AccessDenied);
// ponytail: a 200 saying no (which the route never sends) is held for
// TRUST_CACHE_SECS like any 200; still refused, only slower to flip to yes.
if trusted {
Ok(())
} else {
Err(ProxyError::AccessDenied)
}
Ok(())
}

/// The trusts route's answer. Its status already says yes (200) or no (403);
Expand Down
15 changes: 10 additions & 5 deletions tests/stub_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,15 @@ def _hash(key):
# ── Account trusts ─────────────────────────────────────────────────
# Whether an account trusts a platform token's issuer and subject, at POST
# /api/v1/accounts/{account}/trusts/exchanges (ADR-014). The proxy asks as the
# account itself. Only TRUST_ACCOUNT trusts anyone: GitHub Actions workflows
# in this repository, whatever event minted the token. A counter per account
# lets test_platform_trust.py prove the proxy caches a yes.
# account itself. Only TRUST_ACCOUNT trusts anyone, and only the exact subject
# of the token CI minted for this run (CI_TRUSTED_SUBJECT, set by ci.yml), as
# source.coop matches a trust exactly. SAYS_NO_ACCOUNT answers no with a 200,
# which the real route never does, so the proxy's read of the body is pinned.
# A counter per account lets test_platform_trust.py prove the proxy caches.
TRUST_ACCOUNT = "ci-tests--github-ci"
SAYS_NO_ACCOUNT = "ci-tests--says-no-with-200"
TRUSTED_ISSUER = "https://token.actions.githubusercontent.com"
TRUSTED_SUBJECT_PREFIX = "repo:source-cooperative/data.source.coop:"
TRUSTED_SUBJECT = os.environ.get("CI_TRUSTED_SUBJECT")
TRUST_EXCHANGE_COUNTS = {}

# Who the proxy said it was asking as, per product path, so a test can check
Expand Down Expand Up @@ -212,10 +215,12 @@ def _trust_exchange(self, account):
except (ValueError, KeyError, TypeError):
return self._send(400, b"{}")
TRUST_EXCHANGE_COUNTS[account] = TRUST_EXCHANGE_COUNTS.get(account, 0) + 1
if account == SAYS_NO_ACCOUNT:
return self._send(200, b'{"trusted": false}')
trusted = (
account == TRUST_ACCOUNT
and issuer == TRUSTED_ISSUER
and subject.startswith(TRUSTED_SUBJECT_PREFIX)
and subject == TRUSTED_SUBJECT
)
self._send(200 if trusted else 403, json.dumps({"trusted": trusted}).encode())

Expand Down
Loading
Loading