From 3f49461e7c0acad3e35b21e9af2e7804549071c7 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 10 Jul 2026 08:37:07 +0200 Subject: [PATCH 1/2] ci: build platform images natively and merge manifests Split the Docker publish into per-platform build jobs (amd64 on ubuntu-latest, arm64 on a native arm runner) that push by digest, then a merge job that assembles the multi-arch manifest. Adds metadata-action based tagging, pinned action SHAs, and per-ref concurrency. Co-Authored-By: Claude Fable 5 --- .github/workflows/publish.yml | 129 ++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 61a298b..501a55c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,43 +8,126 @@ on: - "*" env: - IMAGE: ghcr.io/eclipse-cfm/clearglass + GHCR_IMAGE: ghcr.io/${{ github.repository }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - publish: - runs-on: ubuntu-latest + build: + name: Build clearglass for ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: - - uses: actions/checkout@v4 + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Log in to GHCR - uses: docker/login-action@v3 + - name: "Set up Docker Buildx" + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: "Log in to GitHub Container Registry" + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: "Extract metadata" + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ env.GHCR_IMAGE }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} - - name: Determine tags - id: tags - run: | - if [[ "${{ github.ref_type }}" == "tag" ]]; then - echo "tags=${{ env.IMAGE }}:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" - else - echo "tags=${{ env.IMAGE }}:${{ github.sha }},${{ env.IMAGE }}:latest" >> "$GITHUB_OUTPUT" - fi - - - name: Build and push - uses: docker/build-push-action@v6 + - name: "Build and push Docker image" + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0 with: + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} context: . + file: Dockerfile push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.tags.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true + cache-from: type=gha,scope=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + cache-to: type=gha,scope=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }},mode=max + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 + with: + name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: "Merge clearclass Docker Manifests" + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + needs: + - build + + steps: + - name: "Download digests" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: "Set up Docker Buildx" + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: "Extract metadata" + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ env.GHCR_IMAGE }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: "Log in to GitHub Container Registry" + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: "Create and push manifest" + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) From 1dbded5e59ddf81db324108cbd2a080b02fe09f7 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 10 Jul 2026 08:37:53 +0200 Subject: [PATCH 2/2] feat: add route-to-scope map with report-only rollout mode Adds a static YAML route map (ROUTES_FILE) that enforces OAuth2 scope requirements per method and path, evaluated against the X-Forwarded-Method/X-Forwarded-Uri headers Traefik sends: - segment-glob path patterns (* = one segment, trailing ** = remainder) with first-match-wins evaluation and a configurable default decision - scope implication following the EDC grammar :[:], with admin > write > read and api-level/ wildcard scopes covering resource-level requirements - ENFORCE_ROUTES=false (default) runs report-only: violations are logged but only the legacy ?scope= check decides, enabling a safe rollout before flipping enforcement on Legacy verbatim ?scope= checks are unchanged and documented as deprecated in the README. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 14 ++ Cargo.toml | 1 + README.md | 67 +++++++++- routes.example.yaml | 72 ++++++++++ src/main.rs | 314 +++++++++++++++++++++++++++++++++++++++++--- src/routes.rs | 312 +++++++++++++++++++++++++++++++++++++++++++ src/scopes.rs | 189 ++++++++++++++++++++++++++ 7 files changed, 948 insertions(+), 21 deletions(-) create mode 100644 routes.example.yaml create mode 100644 src/routes.rs create mode 100644 src/scopes.rs diff --git a/Cargo.lock b/Cargo.lock index ed880d9..4ef7319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml_ng", "tokio", "tracing", "tracing-subscriber", @@ -2063,6 +2064,19 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sfv" version = "0.10.4" diff --git a/Cargo.toml b/Cargo.toml index fa98b37..f7cecfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } jsonwebtoken = { version = "10", features = ["rust_crypto"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10" [dev-dependencies] wiremock = "0.6" diff --git a/README.md b/README.md index 775d57d..b5613b2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,67 @@ # clearglass -ClearGlass is a small authentication plugin for Traefik that allows to authenticate based on token validity and scopes + +ClearGlass is a small authentication service for Traefik (`ForwardAuth`) that validates Bearer tokens +against the issuer's JWKS and enforces OAuth2 scope requirements. + +## How it works + +Traefik forwards every inbound request to `GET /validate` with the original `Authorization` header +plus `X-Forwarded-Method` / `X-Forwarded-Uri`. clearglass answers: + +- `200` — token is valid and satisfies the scope requirements +- `401` — missing or invalid token +- `403` — token is valid but lacks the required scopes + +Scope requirements come from two sources: + +1. **Legacy `?scope=` query params** on the middleware URL (`/validate?scope=a&scope=b`): the token + must carry at least one of them **verbatim**. This is the pre-route-map mechanism and will be + removed once all deployments use the route map. +2. **A static route→scope map** (`ROUTES_FILE`): method- and path-aware rules evaluated against + `X-Forwarded-Method` / `X-Forwarded-Uri`. See [routes.example.yaml](routes.example.yaml). + +## Route map + +```yaml +default: deny # deny | allow — applies when no rule matches +routes: + - path: /api/identity/participants/*/credentials/** + methods: [GET] # omit to match any method + anyOf: [identity-api:credentials:read] +``` + +- Rules are evaluated top to bottom; the **first** rule whose method and path match decides. + Order the file most-specific-first. +- Path patterns are segment globs: `*` matches exactly one segment, `**` (final segment only) + matches any remainder. +- `anyOf` is satisfied when the token carries at least one scope that **implies** one of the listed + scopes, following the EDC scope grammar `:[:]`: + - actions form a hierarchy: `admin ⊇ write ⊇ read` + - api-level (`identity-api:read`) and wildcard (`identity-api:*:read`) scopes cover any + resource-level requirement (`identity-api:participants:read`); the reverse does not hold + - scopes outside the grammar only match exactly + + Rules should therefore name the **narrowest sufficient scope**; coarser tokens keep working. + +### Rollout: report-only vs enforce + +With `ENFORCE_ROUTES=false` (the default), route-map violations are only logged +(`report-only: ... would be denied with ENFORCE_ROUTES=true`) and the legacy `?scope=` check alone +decides. Run in this mode first, watch the logs for would-be denials, fix the rule file, then set +`ENFORCE_ROUTES=true`. + +## Configuration + +| Env var | Required | Default | Purpose | +|------------------|----------|---------|----------------------------------------------------------------| +| `JWKS_URL` | yes | — | JWKS endpoint used to verify token signatures | +| `PORT` | no | `8080` | Listen port | +| `ROUTES_FILE` | no | — | Path to the route→scope map (YAML); unset = legacy checks only | +| `ENFORCE_ROUTES` | no | `false` | `true` = route map denials are enforced; else report-only | + +## Development + +```shell +cargo build +cargo test +``` diff --git a/routes.example.yaml b/routes.example.yaml new file mode 100644 index 0000000..cf376a5 --- /dev/null +++ b/routes.example.yaml @@ -0,0 +1,72 @@ +# Example route→scope map for clearglass (ROUTES_FILE). +# +# Rules are evaluated top to bottom; the first rule whose method and path match +# decides, so order most-specific-first. `anyOf` names the narrowest sufficient +# scope — wider tokens (api-level, wildcard resource, higher action) satisfy it +# via implication (admin ⊇ write ⊇ read; `identity-api:read` and +# `identity-api:*:read` cover `identity-api::read`). +# +# Paths are matched against X-Forwarded-Uri (query string stripped): +# `*` matches exactly one path segment +# `**` (final segment only) matches any remainder, including nothing +# +# NOTE: patterns below use the EXTERNAL gateway paths (before the gateway's +# prefix rewrite). Verify against the actual X-Forwarded-Uri your Traefik +# version sends (run clearglass in report-only mode and read the logs) before +# enforcing. +# +# `default` applies when no rule matches: deny (recommended) or allow. + +default: deny +routes: + # IdentityHub Identity API + - path: /api/identity/participants/*/credentials/** + methods: [GET] + anyOf: [identity-api:credentials:read] + - path: /api/identity/participants/*/credentials/** + methods: [POST] + anyOf: [identity-api:credentials:write] + - path: /api/identity/participants/** + methods: [GET] + anyOf: [identity-api:participants:read] + - path: /api/identity/participants/** + methods: [POST, PUT, DELETE] + anyOf: [identity-api:participants:write] + + # Control plane Management API + - path: /api/management/** + methods: [GET] + anyOf: [management-api:read] + - path: /api/management/** + methods: [POST, PUT, PATCH, DELETE] + anyOf: [management-api:write] + + # IssuerService Issuer Admin API + - path: /api/issuer/admin/** + methods: [GET] + anyOf: [issuer-admin-api:read] + - path: /api/issuer/admin/** + methods: [POST, PUT, DELETE] + anyOf: [issuer-admin-api:write] + + # CFM Tenant Manager — POST .../query endpoints are reads, so they come first + - path: /api/tm/*/query + methods: [POST] + anyOf: [tenant-manager-api:read] + - path: /api/tm/** + methods: [GET] + anyOf: [tenant-manager-api:read] + - path: /api/tm/** + methods: [POST, PATCH, DELETE] + anyOf: [tenant-manager-api:write] + + # CFM Provision Manager — same query-before-write ordering + - path: /api/pm/*/query + methods: [POST] + anyOf: [provision-manager-api:read] + - path: /api/pm/** + methods: [GET] + anyOf: [provision-manager-api:read] + - path: /api/pm/** + methods: [POST, DELETE] + anyOf: [provision-manager-api:write] diff --git a/src/main.rs b/src/main.rs index ff4f134..fba39c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,14 +10,26 @@ // Metaform Systems, Inc. - initial API and implementation // clearglass is a lightweight Traefik ForwardAuth target that validates -// Bearer tokens via Keycloak's token introspection endpoint (RFC 7662) -// and enforces per-route scope requirements. +// Bearer tokens against the issuer's JWKS and enforces per-route scope +// requirements. // // Traefik calls: GET /validate?scope=&scope= -// - 200 → token is active and has at least one of the listed scopes -// - 401 → missing/inactive token +// - 200 → token is valid and satisfies the scope requirements +// - 401 → missing/invalid token // - 403 → token is valid but lacks the required scopes - +// +// Scope requirements come from two sources: +// 1. legacy `?scope=` query params on the middleware URL — the token must +// carry at least one of them verbatim +// 2. a static route→scope map (ROUTES_FILE) evaluated against the +// X-Forwarded-Method / X-Forwarded-Uri headers Traefik sends. Until +// ENFORCE_ROUTES=true, map violations are only logged (report-only) and +// the legacy check alone decides. + +mod routes; +mod scopes; + +use crate::routes::{Decision, RouteConfig}; use async_trait::async_trait; use http::{Response, StatusCode}; use jsonwebtoken::jwk::Jwk; @@ -146,6 +158,8 @@ impl JwksClient { struct ClearGlassProxy { jwks: Arc, + route_config: Option, + enforce_routes: bool, } impl ClearGlassProxy { @@ -154,8 +168,40 @@ impl ClearGlassProxy { .timeout(Duration::from_secs(5)) .build() .expect("failed to build HTTP client"); + + let route_config = match env::var("ROUTES_FILE") { + Ok(path) => match RouteConfig::load(&path) { + Ok(config) => { + info!(path = %path, rules = config.routes.len(), "loaded route map"); + Some(config) + } + Err(err) => { + eprintln!("failed to load routes file {path}: {err}"); + std::process::exit(1); + } + }, + Err(_) => None, + }; + + let enforce_routes = env::var("ENFORCE_ROUTES") + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if enforce_routes && route_config.is_none() { + eprintln!("ENFORCE_ROUTES=true requires ROUTES_FILE to be set"); + std::process::exit(1); + } + match (&route_config, enforce_routes) { + (Some(_), true) => info!("route map enforcement: ENABLED"), + (Some(_), false) => { + info!("route map enforcement: report-only (set ENFORCE_ROUTES=true to enforce)") + } + (None, _) => warn!("no ROUTES_FILE configured; only legacy ?scope= checks apply"), + } + ClearGlassProxy { jwks: Arc::new(JwksClient::new(must_env("JWKS_URL"), http_client)), + route_config, + enforce_routes, } } @@ -189,6 +235,8 @@ impl ClearGlassProxy { &self, auth_header: Option<&str>, required_token_scopes: &str, + forwarded_method: Option<&str>, + forwarded_uri: Option<&str>, ) -> Response> { let token = match auth_header.and_then(|h| h.strip_prefix("Bearer ")) { Some(t) if !t.is_empty() && t.len() <= 4096 => t, @@ -229,9 +277,64 @@ impl ClearGlassProxy { debug!(required = ?required, "scope check passed"); } + if let Some(resp) = self.check_routes(&scope, forwarded_method, forwarded_uri) { + return resp; + } + debug!("request allowed"); text_response(StatusCode::OK, "ok") } + + /// Evaluates the route map, if configured. Returns a 403 response when the + /// request must be rejected; in report-only mode violations are logged and + /// `None` is returned. + fn check_routes( + &self, + token_scopes: &str, + forwarded_method: Option<&str>, + forwarded_uri: Option<&str>, + ) -> Option>> { + let config = self.route_config.as_ref()?; + + let deny = |log_only_msg: &str, response_body: &str| { + if self.enforce_routes { + Some(text_response(StatusCode::FORBIDDEN, response_body)) + } else { + warn!("report-only: {log_only_msg} — request would be denied with ENFORCE_ROUTES=true"); + None + } + }; + + let (Some(method), Some(uri)) = (forwarded_method, forwarded_uri) else { + warn!(method = ?forwarded_method, uri = ?forwarded_uri, "route check impossible: X-Forwarded-Method/X-Forwarded-Uri missing"); + return deny( + "forwarded method/uri headers missing", + "missing forwarded request headers", + ); + }; + let path = uri.split('?').next().unwrap_or(uri); + + match config.evaluate(method, path, token_scopes) { + Decision::Allow => { + debug!(method = %method, path = %path, "route check passed"); + None + } + Decision::InsufficientScope { + rule_path, + required, + } => { + warn!(method = %method, path = %path, rule = %rule_path, required = ?required, presented = %token_scopes, "route check failed: insufficient scope"); + deny( + "insufficient scope for matched route", + "insufficient scope for route", + ) + } + Decision::NoMatchingRule => { + warn!(method = %method, path = %path, "route check failed: no matching rule (default deny)"); + deny("no matching route rule", "no matching route rule") + } + } + } } #[async_trait] @@ -250,10 +353,28 @@ impl ServeHttp for ClearGlassProxy { .get(http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .map(str::to_owned); + let header = |name: &str| { + http_session + .req_header() + .headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + let fwd_method = header("x-forwarded-method"); + let fwd_uri = header("x-forwarded-uri"); match path.as_str() { "/healthz" => text_response(StatusCode::OK, "ok"), - "/validate" => self.handle_validate(auth.as_deref(), &query).await, + "/validate" => { + self.handle_validate( + auth.as_deref(), + &query, + fwd_method.as_deref(), + fwd_uri.as_deref(), + ) + .await + } _ => { warn!(path = %path, "request for unknown path"); text_response(StatusCode::NOT_FOUND, "not found") @@ -375,6 +496,10 @@ mod tests { /// Creates a proxy with a pre-seeded JWKS cache so no HTTP request is made. fn make_proxy() -> ClearGlassProxy { + make_proxy_with_routes(None, false) + } + + fn make_proxy_with_routes(routes_yaml: Option<&str>, enforce: bool) -> ClearGlassProxy { let mut cache = HashMap::new(); cache.insert(TEST_KID.to_string(), fixture().decoding_key.clone()); ClearGlassProxy { @@ -386,6 +511,8 @@ mod tests { .unwrap(), cache: Arc::new(RwLock::new(cache)), }), + route_config: routes_yaml.map(|y| serde_yaml_ng::from_str(y).expect("routes yaml")), + enforce_routes: enforce, } } @@ -401,21 +528,25 @@ mod tests { #[tokio::test] async fn no_auth_header_returns_401() { - let resp = make_proxy().handle_validate(None, "").await; + let resp = make_proxy().handle_validate(None, "", None, None).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(body(&resp), "missing bearer token"); } #[tokio::test] async fn wrong_auth_scheme_returns_401() { - let resp = make_proxy().handle_validate(Some("Basic abc123"), "").await; + let resp = make_proxy() + .handle_validate(Some("Basic abc123"), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(body(&resp), "missing bearer token"); } #[tokio::test] async fn empty_bearer_returns_401() { - let resp = make_proxy().handle_validate(Some("Bearer "), "").await; + let resp = make_proxy() + .handle_validate(Some("Bearer "), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(body(&resp), "invalid bearer token"); } @@ -423,7 +554,9 @@ mod tests { #[tokio::test] async fn oversized_token_returns_401() { let tok = format!("Bearer {}", "x".repeat(4097)); - let resp = make_proxy().handle_validate(Some(&tok), "").await; + let resp = make_proxy() + .handle_validate(Some(&tok), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(body(&resp), "invalid bearer token"); } @@ -433,14 +566,16 @@ mod tests { #[tokio::test] async fn valid_jwt_no_scope_required_returns_200() { let tok = make_jwt("read"); - let resp = make_proxy().handle_validate(Some(&bearer(&tok)), "").await; + let resp = make_proxy() + .handle_validate(Some(&bearer(&tok)), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn malformed_jwt_returns_401() { let resp = make_proxy() - .handle_validate(Some("Bearer not.a.jwt"), "") + .handle_validate(Some("Bearer not.a.jwt"), "", None, None) .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } @@ -455,9 +590,13 @@ mod tests { .build() .unwrap(), )), + route_config: None, + enforce_routes: false, }; let tok = make_jwt("read"); - let resp = proxy.handle_validate(Some(&bearer(&tok)), "").await; + let resp = proxy + .handle_validate(Some(&bearer(&tok)), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } @@ -482,15 +621,21 @@ mod tests { .build() .unwrap(), )), + route_config: None, + enforce_routes: false, }; let tok = make_jwt("read"); - let resp = proxy.handle_validate(Some(&bearer(&tok)), "").await; + let resp = proxy + .handle_validate(Some(&bearer(&tok)), "", None, None) + .await; assert_eq!(resp.status(), StatusCode::OK); // Second call must hit the cache (wiremock only registered one match). let tok2 = make_jwt("read"); - let resp2 = proxy.handle_validate(Some(&bearer(&tok2)), "").await; + let resp2 = proxy + .handle_validate(Some(&bearer(&tok2)), "", None, None) + .await; assert_eq!(resp2.status(), StatusCode::OK); } @@ -500,7 +645,7 @@ mod tests { async fn token_with_matching_scope_returns_200() { let tok = make_jwt("read write"); let resp = make_proxy() - .handle_validate(Some(&bearer(&tok)), "scope=read") + .handle_validate(Some(&bearer(&tok)), "scope=read", None, None) .await; assert_eq!(resp.status(), StatusCode::OK); } @@ -509,7 +654,7 @@ mod tests { async fn token_satisfying_one_of_multiple_required_scopes_returns_200() { let tok = make_jwt("write"); let resp = make_proxy() - .handle_validate(Some(&bearer(&tok)), "scope=read&scope=write") + .handle_validate(Some(&bearer(&tok)), "scope=read&scope=write", None, None) .await; assert_eq!(resp.status(), StatusCode::OK); } @@ -518,7 +663,7 @@ mod tests { async fn token_missing_required_scope_returns_403() { let tok = make_jwt("read"); let resp = make_proxy() - .handle_validate(Some(&bearer(&tok)), "scope=admin") + .handle_validate(Some(&bearer(&tok)), "scope=admin", None, None) .await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); assert_eq!(body(&resp), "insufficient scope"); @@ -528,7 +673,7 @@ mod tests { async fn empty_scope_param_value_returns_403() { let tok = make_jwt(""); let resp = make_proxy() - .handle_validate(Some(&bearer(&tok)), "scope=") + .handle_validate(Some(&bearer(&tok)), "scope=", None, None) .await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); } @@ -537,11 +682,140 @@ mod tests { async fn non_scope_query_params_are_ignored() { let tok = make_jwt("read"); let resp = make_proxy() - .handle_validate(Some(&bearer(&tok)), "foo=bar&scope=read&baz=qux") + .handle_validate( + Some(&bearer(&tok)), + "foo=bar&scope=read&baz=qux", + None, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + // --- Route map (report-only vs enforce) --- + + const ROUTES: &str = r#" +default: deny +routes: + - path: /api/identity/v1beta/participants/*/credentials/** + methods: [GET] + anyOf: [identity-api:credentials:read] + - path: /api/identity/v1beta/participants/** + methods: [POST] + anyOf: [identity-api:participants:write] +"#; + + #[tokio::test] + async fn enforce_mode_allows_matching_route_and_scope() { + let tok = make_jwt("identity-api:credentials:read"); + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate( + Some(&bearer(&tok)), + "", + Some("GET"), + Some("/api/identity/v1beta/participants/p1/credentials?type=Foo"), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn enforce_mode_allows_wider_scope_via_implication() { + let tok = make_jwt("identity-api:admin"); + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate( + Some(&bearer(&tok)), + "", + Some("POST"), + Some("/api/identity/v1beta/participants"), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn enforce_mode_denies_insufficient_scope() { + let tok = make_jwt("identity-api:credentials:read"); + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate( + Some(&bearer(&tok)), + "", + Some("POST"), + Some("/api/identity/v1beta/participants"), + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body(&resp), "insufficient scope for route"); + } + + #[tokio::test] + async fn enforce_mode_denies_unmatched_route() { + let tok = make_jwt("identity-api:admin"); + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate( + Some(&bearer(&tok)), + "", + Some("DELETE"), + Some("/api/unknown"), + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body(&resp), "no matching route rule"); + } + + #[tokio::test] + async fn enforce_mode_denies_missing_forwarded_headers() { + let tok = make_jwt("identity-api:admin"); + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate(Some(&bearer(&tok)), "", None, None) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn report_only_mode_allows_route_violations() { + let tok = make_jwt("identity-api:credentials:read"); + // would be denied by the route map, but report-only only logs + let resp = make_proxy_with_routes(Some(ROUTES), false) + .handle_validate( + Some(&bearer(&tok)), + "", + Some("POST"), + Some("/api/identity/v1beta/participants"), + ) .await; assert_eq!(resp.status(), StatusCode::OK); } + #[tokio::test] + async fn report_only_mode_still_enforces_legacy_scope_params() { + let tok = make_jwt("identity-api:credentials:read"); + let resp = make_proxy_with_routes(Some(ROUTES), false) + .handle_validate( + Some(&bearer(&tok)), + "scope=identity-api:read", + Some("GET"), + Some("/api/identity/v1beta/participants/p1/credentials"), + ) + .await; + // legacy check is verbatim (no implication) and must keep working unchanged + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body(&resp), "insufficient scope"); + } + + #[tokio::test] + async fn invalid_token_is_401_even_in_enforce_mode() { + let resp = make_proxy_with_routes(Some(ROUTES), true) + .handle_validate( + Some("Bearer not.a.jwt"), + "", + Some("GET"), + Some("/api/unknown"), + ) + .await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + // --- text_response helper --- #[test] diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..3d54a08 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,312 @@ +// Copyright (c) 2026 Metaform Systems, Inc. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License, Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// +// Contributors: +// Metaform Systems, Inc. - initial API and implementation + +//! Static route→scope map. +//! +//! A YAML file declares which scopes a request needs, by method and path: +//! +//! ```yaml +//! default: deny # deny | allow — applies when no rule matches +//! routes: +//! - path: /api/identity/v1beta/participants/*/credentials/** +//! methods: [GET] # omit to match any method +//! anyOf: [identity-api:credentials:read] +//! ``` +//! +//! Path patterns are segment globs: `*` matches exactly one segment, `**` +//! (final segment only) matches any remainder including nothing. Rules are +//! evaluated top to bottom; the FIRST rule whose method and path match decides, +//! so order the file most-specific-first. `anyOf` is satisfied when the token +//! carries at least one scope that satisfies (see `scopes::satisfies`) one of +//! the listed scopes — rules should therefore name the narrowest sufficient +//! scope and rely on implication for wider tokens. + +use crate::scopes; +use serde::Deserialize; +use std::fs; + +type Error = Box; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum DefaultAction { + #[default] + Deny, + Allow, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Route { + pub path: String, + #[serde(default)] + pub methods: Vec, + #[serde(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RouteConfig { + #[serde(default)] + pub default: DefaultAction, + pub routes: Vec, +} + +/// Outcome of evaluating a request against the route map. +#[derive(Debug, PartialEq, Eq)] +pub enum Decision { + /// A rule matched and the token satisfies it, or no rule matched and default is allow. + Allow, + /// A rule matched but the token does not satisfy its scopes. + InsufficientScope { + rule_path: String, + required: Vec, + }, + /// No rule matched and the default is deny. + NoMatchingRule, +} + +impl RouteConfig { + pub fn load(path: &str) -> Result { + let raw = fs::read_to_string(path) + .map_err(|e| format!("failed to read routes file {path}: {e}"))?; + let config: RouteConfig = serde_yaml_ng::from_str(&raw) + .map_err(|e| format!("failed to parse routes file {path}: {e}"))?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), Error> { + for (i, route) in self.routes.iter().enumerate() { + if !route.path.starts_with('/') { + return Err(format!("route[{i}] path must start with '/': {}", route.path).into()); + } + if route.any_of.is_empty() { + return Err(format!("route[{i}] anyOf must not be empty: {}", route.path).into()); + } + let segments: Vec<&str> = route.path.trim_matches('/').split('/').collect(); + if let Some(pos) = segments.iter().position(|s| *s == "**") { + if pos != segments.len() - 1 { + return Err(format!( + "route[{i}] '**' is only allowed as the final segment: {}", + route.path + ) + .into()); + } + } + } + Ok(()) + } + + /// Evaluates `method` and `path` (no query string) against the rules, + /// checking required scopes against the space-separated token `scope` claim. + pub fn evaluate(&self, method: &str, path: &str, token_scopes: &str) -> Decision { + for route in &self.routes { + if !method_matches(&route.methods, method) || !path_matches(&route.path, path) { + continue; + } + return if scopes::any_satisfies(token_scopes.split_whitespace(), &route.any_of) { + Decision::Allow + } else { + Decision::InsufficientScope { + rule_path: route.path.clone(), + required: route.any_of.clone(), + } + }; + } + match self.default { + DefaultAction::Allow => Decision::Allow, + DefaultAction::Deny => Decision::NoMatchingRule, + } + } +} + +fn method_matches(allowed: &[String], method: &str) -> bool { + allowed.is_empty() || allowed.iter().any(|m| m.eq_ignore_ascii_case(method)) +} + +fn path_matches(pattern: &str, path: &str) -> bool { + let pattern_segs: Vec<&str> = pattern.trim_matches('/').split('/').collect(); + let path_segs: Vec<&str> = path.trim_matches('/').split('/').collect(); + + let mut pi = 0; + for (i, pseg) in pattern_segs.iter().enumerate() { + if *pseg == "**" { + // final segment (enforced at load time): matches any remainder + debug_assert!(i == pattern_segs.len() - 1); + return true; + } + let Some(seg) = path_segs.get(pi) else { + return false; + }; + if *pseg != "*" && pseg != seg { + return false; + } + pi += 1; + } + pi == path_segs.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(yaml: &str) -> RouteConfig { + let config: RouteConfig = serde_yaml_ng::from_str(yaml).expect("yaml parse failed"); + config.validate().expect("validation failed"); + config + } + + const SAMPLE: &str = r#" +default: deny +routes: + - path: /api/identity/v1beta/participants/*/credentials/** + methods: [GET] + anyOf: [identity-api:credentials:read] + - path: /api/identity/v1beta/participants/*/credentials/** + methods: [POST] + anyOf: [identity-api:credentials:write] + - path: /api/identity/v1beta/participants/** + methods: [POST, DELETE] + anyOf: [identity-api:participants:write] + - path: /api/identity/** + methods: [GET] + anyOf: [identity-api:read] + - path: /public/health + anyOf: [does-not-matter] +"#; + + #[test] + fn path_glob_semantics() { + assert!(path_matches("/a/b", "/a/b")); + assert!(path_matches("/a/b/", "/a/b")); + assert!(!path_matches("/a/b", "/a")); + assert!(!path_matches("/a", "/a/b")); + + // `*` — exactly one segment + assert!(path_matches("/a/*/c", "/a/b/c")); + assert!(!path_matches("/a/*/c", "/a/c")); + assert!(!path_matches("/a/*/c", "/a/b/d/c")); + + // `**` — any remainder, including nothing + assert!(path_matches("/a/**", "/a")); + assert!(path_matches("/a/**", "/a/b/c")); + assert!(!path_matches("/a/**", "/b")); + } + + #[test] + fn first_match_wins_and_methods_filter() { + let c = config(SAMPLE); + // GET credentials → credentials:read rule, api-level token satisfies via implication + assert_eq!( + c.evaluate( + "GET", + "/api/identity/v1beta/participants/p1/credentials", + "identity-api:read" + ), + Decision::Allow + ); + // narrow token on the exact rule + assert_eq!( + c.evaluate( + "GET", + "/api/identity/v1beta/participants/p1/credentials/request/r1", + "identity-api:credentials:read" + ), + Decision::Allow + ); + // POST credentials needs write; read token fails on that rule (not the GET one) + assert_eq!( + c.evaluate( + "POST", + "/api/identity/v1beta/participants/p1/credentials", + "identity-api:credentials:read" + ), + Decision::InsufficientScope { + rule_path: "/api/identity/v1beta/participants/*/credentials/**".into(), + required: vec!["identity-api:credentials:write".into()], + } + ); + // POST on participants root → participants:write rule + assert_eq!( + c.evaluate( + "POST", + "/api/identity/v1beta/participants", + "identity-api:participants:write" + ), + Decision::Allow + ); + // method case-insensitive + assert_eq!( + c.evaluate( + "post", + "/api/identity/v1beta/participants", + "identity-api:admin" + ), + Decision::Allow + ); + } + + #[test] + fn default_deny_when_no_rule_matches() { + let c = config(SAMPLE); + assert_eq!( + c.evaluate( + "DELETE", + "/api/identity/v1beta/keypairs", + "identity-api:admin" + ), + Decision::NoMatchingRule + ); + assert_eq!( + c.evaluate("GET", "/api/other", "identity-api:admin"), + Decision::NoMatchingRule + ); + } + + #[test] + fn default_allow_when_configured() { + let c = config("default: allow\nroutes: []"); + assert_eq!(c.evaluate("GET", "/anything", ""), Decision::Allow); + } + + #[test] + fn rule_without_methods_matches_any_method() { + let c = config(SAMPLE); + assert_eq!( + c.evaluate("PATCH", "/public/health", "does-not-matter"), + Decision::Allow + ); + } + + #[test] + fn validation_rejects_bad_rules() { + let no_slash: RouteConfig = + serde_yaml_ng::from_str("routes:\n - path: api/x\n anyOf: [a]").unwrap(); + assert!(no_slash.validate().is_err()); + + let empty_scopes: RouteConfig = + serde_yaml_ng::from_str("routes:\n - path: /api/x\n anyOf: []").unwrap(); + assert!(empty_scopes.validate().is_err()); + + let inner_glob: RouteConfig = + serde_yaml_ng::from_str("routes:\n - path: /api/**/x\n anyOf: [a]").unwrap(); + assert!(inner_glob.validate().is_err()); + } + + #[test] + fn unknown_fields_are_rejected() { + let result: Result = + serde_yaml_ng::from_str("routes:\n - path: /a\n anyOf: [x]\n scopes: [y]"); + assert!(result.is_err()); + } +} diff --git a/src/scopes.rs b/src/scopes.rs new file mode 100644 index 0000000..5f168cf --- /dev/null +++ b/src/scopes.rs @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Metaform Systems, Inc. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License, Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// +// Contributors: +// Metaform Systems, Inc. - initial API and implementation + +//! Scope implication following the EDC scope grammar `:[:]` +//! with `action ∈ {read, write, admin}`. +//! +//! A presented scope satisfies a required scope when it is equal or wider: +//! - actions form a hierarchy: `admin ⊇ write ⊇ read` +//! - an api-level scope (`identity-api:read`) or a resource wildcard +//! (`identity-api:*:read`) covers any resource-level requirement +//! (`identity-api:participants:read`) +//! - a resource-level scope does NOT satisfy an api-level requirement +//! +//! Scopes that do not follow the grammar (e.g. plain `read`) only match exactly. +//! These rules must stay aligned with the enforcement inside the EDC services. + +fn action_rank(action: &str) -> Option { + match action { + "read" => Some(1), + "write" => Some(2), + "admin" => Some(3), + _ => None, + } +} + +struct ParsedScope<'a> { + api: &'a str, + resource: Option<&'a str>, + rank: u8, +} + +fn parse(scope: &str) -> Option> { + let parts: Vec<&str> = scope.split(':').collect(); + match parts.as_slice() { + [api, action] if !api.is_empty() => action_rank(action).map(|rank| ParsedScope { + api, + resource: None, + rank, + }), + [api, resource, action] if !api.is_empty() && !resource.is_empty() => action_rank(action) + .map(|rank| ParsedScope { + api, + resource: Some(resource), + rank, + }), + _ => None, + } +} + +/// Does `presented` cover the resource segment demanded by `required`? +fn resource_covers(presented: Option<&str>, required: Option<&str>) -> bool { + match presented { + // api-level and wildcard scopes cover every resource of that api + None | Some("*") => true, + // a concrete resource only covers exactly that resource + Some(p) => matches!(required, Some(r) if p == r), + } +} + +/// Returns true when the `presented` scope satisfies the `required` scope. +pub fn satisfies(presented: &str, required: &str) -> bool { + if presented == required { + return true; + } + let (Some(p), Some(r)) = (parse(presented), parse(required)) else { + return false; + }; + p.api == r.api && p.rank >= r.rank && resource_covers(p.resource, r.resource) +} + +/// Returns true when any of the `presented` scopes satisfies any of the `required` scopes. +pub fn any_satisfies<'a>( + presented: impl Iterator + Clone, + required: &[String], +) -> bool { + required + .iter() + .any(|r| presented.clone().any(|p| satisfies(p, r))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_match_always_satisfies() { + assert!(satisfies("identity-api:read", "identity-api:read")); + // non-grammar scopes match exactly, nothing more + assert!(satisfies("cfm-read", "cfm-read")); + assert!(!satisfies("cfm-read", "cfm-write")); + assert!(!satisfies("read", "identity-api:read")); + } + + #[test] + fn action_hierarchy() { + assert!(satisfies("identity-api:write", "identity-api:read")); + assert!(satisfies("identity-api:admin", "identity-api:read")); + assert!(satisfies("identity-api:admin", "identity-api:write")); + assert!(!satisfies("identity-api:read", "identity-api:write")); + assert!(!satisfies("identity-api:write", "identity-api:admin")); + } + + #[test] + fn api_level_covers_resource_level() { + assert!(satisfies( + "identity-api:read", + "identity-api:participants:read" + )); + assert!(satisfies( + "identity-api:write", + "identity-api:participants:read" + )); + assert!(satisfies( + "identity-api:admin", + "identity-api:participants:write" + )); + } + + #[test] + fn wildcard_resource_covers_specific_and_api_level() { + assert!(satisfies( + "identity-api:*:read", + "identity-api:participants:read" + )); + assert!(satisfies("identity-api:*:read", "identity-api:read")); + assert!(satisfies("identity-api:*:write", "identity-api:dids:read")); + } + + #[test] + fn resource_level_does_not_widen() { + assert!(!satisfies( + "identity-api:participants:read", + "identity-api:read" + )); + assert!(!satisfies( + "identity-api:participants:read", + "identity-api:*:read" + )); + assert!(!satisfies( + "identity-api:participants:write", + "identity-api:dids:write" + )); + } + + #[test] + fn same_resource_action_hierarchy() { + assert!(satisfies( + "identity-api:participants:write", + "identity-api:participants:read" + )); + assert!(!satisfies( + "identity-api:participants:read", + "identity-api:participants:write" + )); + } + + #[test] + fn apis_do_not_cross() { + assert!(!satisfies("management-api:admin", "identity-api:read")); + assert!(!satisfies("identity-api:admin", "issuer-admin-api:read")); + } + + #[test] + fn malformed_scopes_never_widen() { + assert!(!satisfies("identity-api:", "identity-api:read")); + assert!(!satisfies(":read", "identity-api:read")); + assert!(!satisfies("identity-api:foo", "identity-api:read")); + assert!(!satisfies("identity-api:a:b:read", "identity-api:read")); + } + + #[test] + fn any_satisfies_over_sets() { + let required = vec!["identity-api:participants:read".to_string()]; + assert!(any_satisfies( + ["foo", "identity-api:read"].into_iter(), + &required + )); + assert!(!any_satisfies(["foo", "bar"].into_iter(), &required)); + assert!(!any_satisfies([].into_iter(), &required)); + } +}