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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ Set in `wrangler.toml` or via the Cloudflare dashboard:

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):

| Role | Credentials may |
| ------------ | -------------------------------------------------------- |
| `FullAccess` | do everything the account's memberships allow |
| `ReadOnly` | do the same, except write |
| `_default` | do what `FullAccess` does; the name existing clients use |

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.

### Secrets

**GitHub environment secrets are the source of truth.** The deploy workflow
Expand Down
6 changes: 3 additions & 3 deletions adrs/001-s3-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,16 @@ The sealed payload carries:
| `access_key_id` | The identifier the caller signs with |
| `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 (currently always `_default`) |
| `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 |
| `allowed_scopes` | Scope ceiling sealed at mint time — currently empty, and not consulted on this path (see below) |
| `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 |

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 sealed but not enforced on this path.** Its only consumer, `multistore::auth::authorize`, has no call site in the pinned crate; the gateway delegates authorization to the bucket registry instead (ADR-005). Where scopes *are* evaluated, an empty vec means **deny-all**, not unlimited — which is why the registry overrides `authorize_key` rather than inheriting the default. The effective behaviour is "no ceiling", but by bypass rather than by an empty-means-unlimited rule. ADR-011 is where this field would become load-bearing, and wiring it up is part of that work rather than a given.
- **`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).
- **Authenticated encryption.** GCM provides integrity as well as confidentiality: a tampered token fails to decrypt rather than decoding into attacker-chosen values.

Expand Down
4 changes: 3 additions & 1 deletion adrs/004-sts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
**RFC:** RFC-001 §7
**Depends on:** ADR-001
**Implementation:** `src/sts.rs`, `src/lib.rs`, `src/config.rs`; `source.coop:src/lib/actions/proxy-credentials.ts`
**Implemented by:** #116 (initial `/.sts` exchange), #163 (multiple accepted audiences), #165 (configurable max session duration), #185 (ARN-shaped `_default` alias), #196 (form-encoded POST bodies, wiring [multistore#112](https://github.com/developmentseed/multistore/pull/112)) · source.coop#283 (OIDC auth), source.coop#391 (in-browser uploads via the proxy), source.coop#402 (mid-upload credential refresh)
**Implemented by:** #116 (initial `/.sts` exchange), #163 (multiple accepted audiences), #165 (configurable max session duration), #185 (ARN-shaped `_default` alias), #196 (form-encoded POST bodies, wiring [multistore#112](https://github.com/developmentseed/multistore/pull/112)), #236 (`FullAccess` and `ReadOnly` Roles) · source.coop#283 (OIDC auth), source.coop#391 (in-browser uploads via the proxy), source.coop#402 (mid-upload credential refresh)

---

Expand Down Expand Up @@ -75,6 +75,8 @@ A single built-in Role, `_default`, is served from a hardcoded registry:

> [!NOTE]
> **Amended by ADR-014 (Service Accounts).** For a platform issuer's token, the account portion of `RoleArn` is no longer ignored: it names the service account whose trust in the token's issuer and subject is checked, and the credentials act as that account. For an Ory ID token it is still ignored, because the token itself names the person.
>
> Two more hardcoded Roles are served alongside it (ADR-014, #236): `FullAccess`, of which `_default` is now an alias, and `ReadOnly`, whose read-only ceiling is sealed into the session and enforced by the bucket registry (ADR-011). All three take the same ARN form.

### Trust Model — Issuer and Audience

Expand Down
2 changes: 1 addition & 1 deletion adrs/011-role-ceiling-authorization.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-011: Role-Ceiling Authorization

**Status:** Proposed — not implemented
**Status:** Proposed — implemented in part (#236: step 2 and the denial semantics, for the hardcoded `ReadOnly` Role's action ceiling)
**Date:** 2026-08-09
**RFC:** RFC-001 §8
**Depends on:** ADR-005, ADR-010
Expand Down
28 changes: 23 additions & 5 deletions src/authz.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! Authorization for product backends: write-action classification and the
//! authorization → federation decision ([`decide_backend_auth`]). Kept wasm-free
//! so both can be unit-tested natively (see `tests/authz.rs`), despite the
//! crate's `[lib] test = false`.
//! Authorization for product backends: write-action classification, the Role
//! ceiling ([`ceiling_permits`]) and the authorization → federation decision
//! ([`decide_backend_auth`]). Kept wasm-free so all three can be unit-tested
//! natively (see `tests/authz.rs`), despite the crate's `[lib] test = false`.

use std::collections::HashMap;

use multistore::error::ProxyError;
use multistore::types::Action;
use multistore::types::{AccessScope, Action};

use crate::backend_auth::{apply_backend_auth, BackendAuth};
use crate::sts::ALL_PRODUCTS;

/// Whether an S3 action mutates the backend. Reads (GET/HEAD/LIST) are served
/// without a write check; everything else is a write and must be authorized.
Expand All @@ -24,6 +25,23 @@ pub(crate) fn is_write_action(action: Action) -> bool {
)
}

/// Whether the Role ceiling sealed into a session (ADR-011) allows `action`.
/// Checked before anything is fetched, and it only subtracts: what it allows
/// still needs the account's own permissions.
///
/// No scopes means no ceiling: `FullAccess` and `_default` seal none. Otherwise
/// a scope must name every product ([`ALL_PRODUCTS`]) with no prefix and list
/// the action. The proxy seals nothing narrower, so a narrower scope is refused
/// rather than guessed at.
pub(crate) fn ceiling_permits(scopes: &[AccessScope], action: Action) -> bool {
scopes.is_empty()
|| scopes.iter().any(|scope| {
scope.bucket == ALL_PRODUCTS
&& scope.prefixes.is_empty()
&& scope.actions.contains(&action)
})
}

/// Authorize a resolved product's request and, only on success, translate the
/// connection's backend authentication into multistore `backend_options`. This
/// is the single authorization → federation seam: `resolve_product` performs the
Expand Down
16 changes: 8 additions & 8 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,9 +581,14 @@ async fn exchange_api_key(
tracing::warn!(%request_id, reason = "malformed", "API key exchange refused");
return Err(ProxyError::InvalidOidcToken("malformed".into()));
};
if !sts::is_default_role(&sts.role_arn) {
let Some(role) = sts::role(
&sts.role_arn,
config.auth_issuer.clone(),
config.auth_audiences.clone(),
config.sts_max_session_duration_secs,
) else {
return Err(ProxyError::RoleNotFound(sts.role_arn.clone()));
}
};
let key_hash = keys::key_hash(key);
let standing = source_api::cache::get_or_fetch_key_standing(
&config.api_base_url,
Expand All @@ -609,18 +614,13 @@ async fn exchange_api_key(
return Err(ProxyError::InvalidOidcToken("inactive".into()));
}
};
let role = sts::default_role(
config.auth_issuer.clone(),
config.auth_audiences.clone(),
config.sts_max_session_duration_secs,
);
let creds = keys::credentials_for(
&role,
&account_id,
sts.duration_seconds,
&config.session_token_key,
)?;
tracing::info!(%request_id, key_id, %account_id, "API key exchanged");
tracing::info!(%request_id, key_id, %account_id, role = %role.role_id, "API key exchanged");
Ok(creds)
}

Expand Down
19 changes: 17 additions & 2 deletions src/source_api/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use multistore::error::ProxyError;
use multistore::registry::{BucketRegistry, ResolvedBucket};
use multistore::types::{Action, BucketConfig, ResolvedIdentity, S3Operation};

use crate::authz::{decide_backend_auth, is_write_action};
use crate::authz::{ceiling_permits, decide_backend_auth, is_write_action};

/// Registry that resolves Source Cooperative products to multistore `BucketConfig`s
/// by calling the Source Cooperative API.
Expand Down Expand Up @@ -56,7 +56,22 @@ impl BucketRegistry for SourceCoopRegistry {
.ok_or_else(|| ProxyError::BucketNotFound(name.to_string()))?;

let subject = match identity {
ResolvedIdentity::Authenticated(auth) => Some(auth.principal_name.as_str()),
ResolvedIdentity::Authenticated(auth) => {
// The Role ceiling goes first and is local (ADR-011): a session
// whose Role does not allow the action is refused before
// anything is fetched, with the AccessDenied every other
// refusal gets, so the answer says nothing about the product.
// Only this log line records why.
if !ceiling_permits(&auth.allowed_scopes, operation.action()) {
tracing::info!(
principal = %auth.principal_name,
action = ?operation.action(),
"refused by the Role ceiling"
);
return Err(ProxyError::AccessDenied);
}
Some(auth.principal_name.as_str())
}
ResolvedIdentity::Anonymous => None,
};

Expand Down
111 changes: 66 additions & 45 deletions src/sts.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
//! STS credential registry for token exchange.
//!
//! Provides a hardcoded `_default` role that trusts the Source Cooperative auth
//! provider, enabling clients to exchange OIDC tokens for temporary S3-style credentials.
//! Serves the hardcoded Roles (ADR-014): `FullAccess`, everything the caller's
//! memberships allow, and `ReadOnly`, the same with writing removed. `_default`
//! is `FullAccess` under the name existing clients already use. There is no
//! lookup: account-owned Roles (ADR-010) are deferred, and a Role only ever
//! subtracts from the account's own permissions, so any caller may name either.

use multistore::error::ProxyError;
use multistore::registry::CredentialRegistry;
use multistore::types::{RoleConfig, StoredCredential};
use multistore::types::{AccessScope, Action, RoleConfig, StoredCredential};

/// Credential registry that serves a single hardcoded `_default` role.
///
/// The default role trusts the Source Cooperative auth provider with no scope
/// restrictions, so any user holding a token for one of the configured
/// audiences (`required_audiences`) can obtain temporary credentials.
/// The bucket a Role's scope names to cover every product. Only the proxy's
/// registry reads scopes — multistore's own scope check never runs on this
/// gateway — so the wildcard means what `authz::ceiling_permits` says it does.
pub(crate) const ALL_PRODUCTS: &str = "*";

/// Credential registry that serves the hardcoded Roles.
#[derive(Clone)]
pub struct StsCredentialRegistry {
default_role: RoleConfig,
oidc_issuer: String,
required_audiences: Vec<String>,
max_session_duration_secs: u64,
}

impl StsCredentialRegistry {
/// Create a new registry whose `_default` role trusts the given auth issuer.
/// Create a new registry whose Roles trust the given auth issuer.
///
/// `required_audiences` restricts token exchange to subject tokens minted
/// for one of these OAuth clients (the `aud` claim); a token is accepted if
Expand All @@ -36,29 +42,62 @@ impl StsCredentialRegistry {
max_session_duration_secs: u64,
) -> Self {
Self {
default_role: default_role(oidc_issuer, required_audiences, max_session_duration_secs),
oidc_issuer,
required_audiences,
max_session_duration_secs,
}
}
}

/// The `_default` role: trusts `oidc_issuer` for tokens minted for one of
/// `required_audiences`, with no scope restriction. Shared with the API-key
/// exchange, which mints under the same role once the API has named the
/// account (`keys::credentials_for`).
pub(crate) fn default_role(
/// The Role `role_arn` names, trusting `oidc_issuer` for tokens minted for one
/// of `required_audiences`; `None` for a name the proxy does not serve. Never a
/// fallback: a workload that asks for a Role it cannot have fails at exchange
/// rather than receiving different access than it asked for. Shared with the
/// API-key exchange, which mints under the named Role once the API has named
/// the account (`keys::credentials_for`).
pub(crate) fn role(
role_arn: &str,
oidc_issuer: String,
required_audiences: Vec<String>,
max_session_duration_secs: u64,
) -> RoleConfig {
RoleConfig {
role_id: "_default".to_string(),
name: "Default".to_string(),
) -> Option<RoleConfig> {
let name = role_name(role_arn)?;
let allowed_scopes = match name {
// No scopes, no ceiling: the account's permissions are the only limit.
"FullAccess" | "_default" => vec![],
// Sealed into the session; `authz::ceiling_permits` enforces it.
"ReadOnly" => vec![AccessScope {
bucket: ALL_PRODUCTS.to_string(),
prefixes: vec![],
actions: vec![Action::GetObject, Action::HeadObject, Action::ListBucket],
}],
_ => return None,
};
Some(RoleConfig {
role_id: name.to_string(),
name: name.to_string(),
trusted_oidc_issuers: vec![oidc_issuer],
required_audiences,
subject_conditions: vec![],
allowed_scopes: vec![], // unlimited
allowed_scopes,
max_session_duration_secs,
})
}

/// The Role name in `role_arn`: a bare name, or the `role/<name>` resource of
/// an ARN of any partition and account, such as
/// `arn:aws:iam::000000000000:role/ReadOnly`.
///
/// The ARN form exists because AWS SDKs validate `RoleArn` client-side (ARN
/// shape, 20-character minimum) before the request is ever sent, so a bare name
/// can't reach the server from standard tooling (see
/// source-cooperative/data.source.coop#184). The partition and account carry no
/// meaning for the Role itself, so they are ignored rather than validated.
fn role_name(role_arn: &str) -> Option<&str> {
if !role_arn.starts_with("arn:") {
return Some(role_arn);
}
role_arn.splitn(6, ':').nth(5)?.strip_prefix("role/")
}

impl CredentialRegistry for StsCredentialRegistry {
Expand All @@ -71,29 +110,11 @@ impl CredentialRegistry for StsCredentialRegistry {
}

async fn get_role(&self, role_id: &str) -> Result<Option<RoleConfig>, ProxyError> {
// TODO: Eventually look up roles via the Source Cooperative API so that
// individual repositories can define custom roles with fine-grained
// scope and subject restrictions (e.g. per-repo CI/CD access).
// For now, only the hardcoded `_default` role is supported.
if is_default_role(role_id) {
Ok(Some(self.default_role.clone()))
} else {
Ok(None)
}
Ok(role(
role_id,
self.oidc_issuer.clone(),
self.required_audiences.clone(),
self.max_session_duration_secs,
))
}
}

/// Whether `role_id` names the `_default` role — literally, or via an
/// ARN-shaped alias whose resource is `role/_default` (any partition/account,
/// e.g. `arn:aws:iam::000000000000:role/_default`).
///
/// The alias exists because AWS SDKs validate `RoleArn` client-side (ARN shape,
/// 20-character minimum) before the request is ever sent, so a bare `_default`
/// can't reach the server from standard tooling. Accepting the alias keeps
/// `/.sts` a drop-in `AssumeRoleWithWebIdentity` target for unmodified SDKs
/// (see source-cooperative/data.source.coop#184). Same role, same trust model —
/// only the name is longer; the partition/account portion is ignored rather
/// than validated because it carries no meaning here.
pub(crate) fn is_default_role(role_id: &str) -> bool {
role_id == "_default" || (role_id.starts_with("arn:") && role_id.ends_with(":role/_default"))
}
Loading
Loading