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
129 changes: 106 additions & 23 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' *)
14 changes: 14 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 `<api>:[<resource>:]<action>`:
- 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
```
72 changes: 72 additions & 0 deletions routes.example.yaml
Original file line number Diff line number Diff line change
@@ -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:<resource>: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]
Loading
Loading