Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7234f89
fix(consent): key the credential cache on the full credential identit…
wistefan Aug 27, 2026
18fccf4
fix(plugin)!: remove the legacy JWT-subject mode; require an OwnerRes…
wistefan Aug 27, 2026
06fe591
fix(consent): scope the consent match to the consuming participant an…
wistefan Aug 27, 2026
4728098
fix(plugin): fail closed when the exchange parties cannot be resolved…
wistefan Aug 27, 2026
7e988b1
fix(plugin)!: default to fail-closed and never fail open on misconfig…
wistefan Aug 27, 2026
be85548
fix(plugin): bound the request-context store and stop retaining beare…
wistefan Aug 27, 2026
a1eea8e
docs: rewrite README and CLAUDE.md against the real config; guard dri…
wistefan Aug 27, 2026
258a1c1
fix(plugin): never fail open on a consumer missing from the participa…
wistefan Aug 27, 2026
57b0e06
test: cover the owner-resolver path and gate coverage in CI (H-4)
wistefan Aug 27, 2026
1f79e03
perf(plugin): bound the response-phase fan-out (H-5)
wistefan Aug 27, 2026
cfd7501
fix(plugin): strip upstream headers from the denial response (M-1)
wistefan Aug 27, 2026
043bc00
fix(config): reject a configuration that cannot authenticate or addre…
wistefan Aug 27, 2026
025113e
fix(audit): make the access record complete, flushed and safe to expo…
wistefan Aug 27, 2026
ad39a25
fix(plugin): traverse arrays in the consumer claim path (M-4)
wistefan Aug 27, 2026
51802f1
refactor(logging): leveled, redacted, rate-limited logging (M-5)
wistefan Aug 27, 2026
36e3f7b
fix(dev): make `docker compose up` actually work (M-6)
wistefan Aug 27, 2026
0573e31
ci: pin the scanners and let them fail the build (M-7)
wistefan Aug 27, 2026
cc58f0c
refactor: delete the dead field-filtering model (L-1)
wistefan Aug 27, 2026
a7a18d4
docs: fix package docs that contradicted the behaviour (L-2)
wistefan Aug 27, 2026
7c50c72
build: harden the runtime image (L-3)
wistefan Aug 27, 2026
18354df
build(deps): refresh direct dependencies and add Dependabot (L-4)
wistefan Aug 27, 2026
9077f66
fix(plugin): stop marking every allowed response as modified (L-5)
wistefan Aug 27, 2026
2c19114
feat(metrics): expose the gate's operational signals (L-6)
wistefan Aug 27, 2026
e5e65da
fix(config): bound participant_token_ttl so it cannot overflow (L-7)
wistefan Aug 27, 2026
5a6f8c9
docs: add SECURITY.md and CODEOWNERS (L-8)
wistefan Aug 27, 2026
e7b4f52
docs: document the local Go toolchain requirement (L-9)
wistefan Aug 27, 2026
3f6698d
fix(plugin): cap the payload forwarded to the OwnerResolver (L-10)
wistefan Aug 27, 2026
6528a89
fix(ownerresolver): distinguish an unreadable payload from no payload…
wistefan Aug 27, 2026
46a6614
chore: keep review.md out of the repository
wistefan Aug 27, 2026
0048714
test: cover the phase-correlation gap (§5)
wistefan Aug 27, 2026
77f8aff
fix: run the audit flush after the runner returns, not beside it (N-1)
wistefan Aug 27, 2026
f73812b
fix(plugin): rank a definite deny above a dependency error (N-2)
wistefan Aug 27, 2026
29c58c8
perf(plugin): amortise the request-context overflow eviction (N-3)
wistefan Aug 27, 2026
acdb6b6
test(plugin): make the N-2 ranking test deterministic
wistefan Aug 27, 2026
8d196d3
feat(plugin): surface and optionally require purpose scoping (N-4)
wistefan Aug 27, 2026
192aa72
fix(consent): keep dependency response bodies out of errors that esca…
wistefan Aug 27, 2026
afb0003
fix(metrics): declare counters as counters and give every family a HE…
wistefan Aug 27, 2026
82974a5
test: close the residual coverage gaps and benchmark the overflow pat…
wistefan Aug 27, 2026
484616a
fix(dev): repair the dev stack, which validated but could not start (…
wistefan Aug 27, 2026
91b40cd
move image to seamware
wistefan Aug 27, 2026
74a771f
fixed ci
wistefan Aug 27, 2026
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
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Keep the build context small and stable. Without this, `COPY . .` pulls in
# .git, so every commit changes the context digest and invalidates the build
# cache even when no source file changed.
.git
.github
.gitea
.golangci.yml
.dockerignore
Dockerfile
docker-compose.yaml
dev
hack
coverage.out
go-runner
*.md
LICENSE
3 changes: 2 additions & 1 deletion .gitea/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ jobs:
- name: Install golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v2.1.6
# Must match .github/workflows/style-guide.yml.
version: v2.13.1
args: ./...

- name: Run tests with coverage
Expand Down
16 changes: 16 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Review is required on every change: this repository decides whether personal
# data is released, so nothing here is too small to be looked at by someone else.
* @wistefan

# The decision path itself. A change here can turn the gate into a pass-through.
/internal/plugin/ @wistefan
/internal/consent/ @wistefan
/internal/ownerresolver/ @wistefan

# The audit trail, which is the evidence that the gate worked.
/internal/audit/ @wistefan

# Security policy, CI gates and the release path.
/SECURITY.md @wistefan
/.github/ @wistefan
/.gitea/ @wistefan
40 changes: 40 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Dependency updates.
#
# The direct dependencies had drifted years behind (testify 1.8.4 while 1.12.1
# was current) with nothing to notice. This makes the drift a pull request
# instead of a review finding.
#
# The go-plugin-runner is deliberately grouped on its own: it is pinned at v0.5.0
# and its transitive tree (zap 1.17, flatbuffers 2.0.0) is old, so an update to
# it is a decision to make deliberately rather than merge with the batch. A
# plugin whose runner is unmaintained is a strategic risk worth surfacing
# regularly.
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
go-dependencies:
patterns:
- "*"
exclude-patterns:
- "github.com/apache/apisix-go-plugin-runner"
labels:
- dependencies

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels:
- dependencies

- package-ecosystem: docker
directory: /
schedule:
interval: weekly
labels:
- dependencies
2 changes: 1 addition & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ structure: quality gates run on every PR and on `main`, and releases are

Each release produces:

- **Container image** — `quay.io/wi_stefan/consent-plugin:<version>` (plus `:latest`
- **Container image** — `quay.io/seamware/consent-plugin:<version>` (plus `:latest`
and `:<sha>`), multi-arch `linux/amd64,linux/arm64`. **This is the primary
deployment artifact**: the APISIX deployment's init container copies
`/app/go-runner` out of the image into the `ext-plugin` volume
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ concurrency:

env:
REGISTRY: quay.io
REPOSITORY: wi_stefan
REPOSITORY: seamware
IMAGE_NAME: consent-plugin

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:

env:
REGISTRY: quay.io
REPOSITORY: wi_stefan
REPOSITORY: seamware
IMAGE_NAME: consent-plugin

jobs:
Expand Down
48 changes: 41 additions & 7 deletions .github/workflows/security-analysis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
name: Security Analysis

# Reusable source-level security scanning. Findings are uploaded as SARIF to
# the GitHub Security tab; scans never block the pipeline (continue-on-error).
# Reusable source-level security scanning.
#
# The scans BLOCK the pipeline. They previously ran with continue-on-error, so a
# known-vulnerable dependency merged cleanly and the findings were informational
# only — a security gate that cannot fail is a dashboard, not a gate. SARIF is
# still uploaded to the Security tab either way.
#
# Tool versions are pinned. `@latest` made CI non-reproducible (a scanner release
# could redden an untouched PR) and put an unpinned binary in the build's trust
# boundary. Keep GOSEC_VERSION and the golangci-lint version in .gitea/workflows/
# and .github/workflows/style-guide.yml in step, so the two pipelines cannot
# disagree about whether the code passes.
#
# A pin still has to be able to BUILD. gosec is installed from source with the
# toolchain from go.mod, so its own dependency tree must compile under that Go
# version: gosec v2.21.4 pinned golang.org/x/tools v0.25.0, which reaches into
# the internal layout of go/token via unsafe and guards it with a deliberate
# compile-time tripwire ("if the size of token.FileSet changes, this will fail to
# compile"). Go 1.26 changed that layout, so the guard fired and the step could
# never build:
#
# x/tools@v0.25.0/internal/tokeninternal/tokeninternal.go:64:9:
# invalid array length -delta * delta (constant -256 of type int64)
#
# Any pre-Go-1.26 x/tools is affected, so a scanner pin must be advanced together
# with the toolchain in go.mod. v2.29.0 builds on golang.org/x/tools v0.49.0.
on:
workflow_call:

env:
GOSEC_VERSION: v2.29.0

jobs:
govulncheck:
runs-on: ubuntu-latest
Expand All @@ -17,8 +44,12 @@ jobs:
with:
go-version-file: go.mod

# Verifies the module cache against go.sum before anything is built with it.
- name: Verify module checksums
run: go mod verify

- uses: golang/govulncheck-action@v1.0.4
continue-on-error: true
id: govulncheck
with:
go-version-file: go.mod
output-format: sarif
Expand All @@ -40,11 +71,14 @@ jobs:
with:
go-version-file: go.mod

- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@${{ env.GOSEC_VERSION }}

# -no-fail is deliberately NOT passed: a new finding must fail the PR.
# Suppress a reviewed finding at the call site with a #nosec comment
# carrying the reason, so the exception is visible in the diff.
- name: Run gosec
continue-on-error: true
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -fmt sarif -out gosec-results.sarif ./...
run: gosec -fmt sarif -out gosec-results.sarif ./...

- uses: github/codeql-action/upload-sarif@v4
if: always()
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/style-guide.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: Style Guide

# Reusable lint check using the repository's .golangci.yml.
#
# The version is PINNED and must match .gitea/workflows/ci.yaml. With `latest`
# here and a pin there, the two pipelines could disagree about whether the same
# commit lints — and a golangci-lint release could redden an untouched PR.
on:
workflow_call:

Expand All @@ -17,4 +21,4 @@ jobs:

- uses: golangci/golangci-lint-action@v9
with:
version: latest
version: v2.13.1
8 changes: 7 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ jobs:
with:
go-version-file: go.mod

# -coverpkg=./... attributes the integration package's coverage of the
# packages it exercises; without it those statements are discarded and the
# reported figure understates reality.
- name: Run tests (race + coverage)
run: go test -race -coverprofile=coverage.out ./...
run: go test -race -coverpkg=./... -coverprofile=coverage.out ./...

- name: Coverage summary
run: go tool cover -func=coverage.out

- name: Enforce the coverage floor
run: make coverage-floor

- uses: actions/upload-artifact@v4
with:
name: coverage
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ coverage.out

# OS files
.DS_Store

# Local review/working notes, not part of the repository.
review.md
115 changes: 85 additions & 30 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
# consent-plugin

## Overview
An Apache APISIX Go plugin that gates access to personal data on the data
subject's **consent**. It uses the APISIX go-plugin-runner to hook into the
request/response lifecycle: the request phase (`ext-plugin-pre-req`) captures
the JWT `sub`, and the response phase (`ext-plugin-post-resp`) runs a **two-call
check** against a Prometheus-X / Visions consent-manager (resolve the subject's
`userIdentifier`, then list its consents) and allows the response only when a
granted consent exists — otherwise it replaces the response with a configurable
deny. The two phases are correlated by the Nginx `$request_id` (not the runner's
per-RPC `ID()`). The gate is coarse (allow/deny) and independent of the response
body; there is no field-level filtering.
An Apache APISIX Go plugin that gates access to personal data on the **consent
of the data owner**. It uses the APISIX go-plugin-runner to hook into the
request/response lifecycle: the request phase (`ext-plugin-pre-req`) captures the
JWT claims, and the response phase (`ext-plugin-post-resp`) asks an external
**OwnerResolver** who owns the data in the upstream payload, then runs a
**two-call check** against a Prometheus-X / Visions consent-manager per resolved
owner (resolve the owner's `userIdentifier`, then list its consents) and allows
the response only when every owner has a granted consent **for this consuming
participant**. Otherwise the response is replaced with a configurable deny.

Ownership never comes from the requestor: the token's `sub` says who is asking,
not whose data is returned. `owner_resolver_url` is therefore required. The two
phases are correlated by the Nginx `$request_id` (not the runner's per-RPC
`ID()`). The gate is coarse (allow/deny) and independent of the response body's
shape; there is no field-level filtering.

The plugin decodes but does **not verify** the JWT — an authentication plugin
earlier in the route is a hard prerequisite.

## Tech Stack
- Language: Go 1.21+
- Language: Go (see `go.mod` for the pinned version)
- Framework: Apache APISIX go-plugin-runner (`github.com/apache/apisix-go-plugin-runner`)
- Test: Go standard `testing` package with `testify` for assertions
- Build: Makefile + Docker
Expand All @@ -22,29 +30,46 @@ body; there is no field-level filtering.
```
consent-plugin/
├── CLAUDE.md # This file — AI agent codebase context
├── IMPLEMENTATION_PLAN.md # Step-by-step implementation plan
├── README.md # Project README
├── README.md # Project README (the config surface is contract)
├── Makefile # Build, test, lint targets
├── Dockerfile # Build the go-runner binary
├── go.mod # Go module definition
├── go.sum # Go dependency checksums
├── go.mod / go.sum # Go module definition and checksums
├── main.go # Entry point — registers plugin, starts runner
├── internal/
│ ├── plugin/
│ │ ├── consent.go # Plugin struct, Name(), ParseConf(), RequestFilter(), ResponseFilter()
│ │ ├── config.go # Configuration schema and validation
│ │ ├── context.go # Bounded request-context store keyed by $request_id
│ │ ├── consent_test.go # Unit tests for plugin logic
│ │ └── config.go # Configuration schema struct and validation
│ │ ├── config_test.go # Unit tests for configuration
│ │ ├── config_doc_test.go # Doc-drift guard: README must document every config field
│ │ └── context_test.go # Unit tests for the context store
│ ├── consent/
│ │ ├── client.go # HTTP client for external consent API
│ │ ├── client_test.go # Unit tests for consent client
│ │ └── models.go # Request/response models for consent API
│ │ ├── client.go # Two-call consent-manager client
│ │ ├── client_test.go # Unit tests for the consent client
│ │ └── models.go # Request/response models for the consent check
│ ├── ownerresolver/
│ │ ├── client.go # OwnerResolver /resolve client (who owns the data)
│ │ └── client_test.go # Unit tests for the resolver client
│ ├── audit/
│ │ ├── audit.go # OTLP/HTTP access-decision audit exporter
│ │ └── audit_test.go # Unit tests for the audit exporter
│ ├── metrics/
│ │ ├── metrics.go # Prometheus text-format exporter (decisions, latency, gauges)
│ │ └── metrics_test.go # Unit tests for the exporter
│ ├── logging/
│ │ ├── logging.go # Leveled logging front end: redaction, sanitisation, rate limiting
│ │ └── logging_test.go # Unit tests for the logging front end
│ ├── jwt/
│ │ ├── extractor.go # JWT extraction and parsing from request headers
│ │ ├── extractor.go # JWT extraction and claim decoding (no verification)
│ │ └── extractor_test.go # Unit tests for JWT extraction
│ └── filter/
│ ├── response.go # JSON response body filtering/redaction logic
│ └── response_test.go # Unit tests for response filtering
└── docker-compose.yaml # Local dev with APISIX + plugin runner
│ └── integration/
│ └── integration_test.go # End-to-end plugin lifecycle tests
├── dev/
│ ├── apisix-config.yaml # APISIX config for the local stack (ext-plugin wiring)
│ ├── otel-collector.yaml # Collector config receiving the audit log
│ └── mocks/ # WireMock stubs: consent-manager, OwnerResolver, token service
└── docker-compose.yaml # Local dev with APISIX + plugin runner + mocks
```

## Build & Test
Expand Down Expand Up @@ -75,9 +100,39 @@ make docker-build

## Important Files
- `main.go` — Entry point; registers the consent plugin and starts the runner.
- `internal/plugin/consent.go` — Core plugin: `RequestFilter` captures context (keyed by `$request_id`), `ResponseFilter` runs the two-call check and allows/denies.
- `internal/plugin/config.go` — Plugin configuration schema (consent-manager URL + prefix, `consent_key`, participant `client_id`/`client_secret` (or a static `participant_token`), optional `provider_sd`, JWT settings, deny behavior, `fail_open`). `consent_key` is **optional** (the authority's facade injects it and overrides anything sent). `consent_key`/`client_id`/`client_secret` fall back to env vars `CONSENT_KEY`/`CONSENT_CLIENT_ID`/`CONSENT_CLIENT_SECRET` (config wins) so the secret stays out of the route config; `applyEnv()` runs in `ParseConfig`.
- `internal/plugin/context.go` — Concurrent request-context store bridging the two phases, keyed by the Nginx `$request_id`.
- `internal/consent/client.go` — Consent-manager client: participant client-credentials login (`/participants/login`, token cached/refreshed) + provider-SD derivation (`/participants/me`), then the two-call check (`/users/identifier/search` + `/consents/participants/{id}`). Token/SD cache is keyed per participant with a per-entry lock, so concurrent first requests coalesce onto one login without a global lock across the HTTP call.
- `internal/audit/audit.go` — Access-decision audit emitter: exports one OTLP/HTTP log record per decision to the OTel Collector (marked `service.name=consent-access-audit` for routing). Async, batched, best-effort (bounded queue drops rather than blocking); gated by `audit_enabled` + `audit_otlp_endpoint`. `ResponseFilter` → `recordAudit` calls it.
- `go.mod` — Module path: `consent-plugin` (or as configured).
- `internal/plugin/consent.go` — Core plugin: `RequestFilter` captures context
(keyed by `$request_id`), `ResponseFilter` resolves the data owners and runs
the per-owner check. `failMode` distinguishes dependency outages (governed by
`fail_open`) from structural failures that always deny.
- `internal/plugin/config.go` — Plugin configuration schema. `owner_resolver_url`
is **required**; `fail_open` defaults to **false**. `consent_key`,
`token_service_url` and `audit_otlp_endpoint` fall back to `CONSENT_KEY`,
`CONSENT_TOKEN_SERVICE_URL` and `CONSENT_AUDIT_OTLP_ENDPOINT` (config wins) so
secrets stay out of the route config; `applyEnv()` runs in `ParseConfig`.
- `internal/plugin/config_doc_test.go` — Fails the build when the README's
configuration table and the `Config` json tags disagree in either direction.
- `internal/plugin/context.go` — Bounded request-context store bridging the two
phases, keyed by the Nginx `$request_id`: TTL, size cap, background sweep, and
size/eviction gauges. It deliberately holds no request headers.
- `internal/ownerresolver/client.go` — Client for the external OwnerResolver
`/resolve` endpoint, which answers from the DATA alone who the owners are and
whether consent is required. `parties` is for contract identification only.
- `internal/consent/client.go` — Consent-manager client: token from the
participant-local OID4VP token service (`token_service_url`, cached/refreshed
per credential identity) + provider-SD derivation (`/participants/me`), the
participant registry (`/participants`) for DID → self-description mapping, and
the two-call check (`/users/identifier/search` + `/consents/participants/{id}`).
A consent counts only if it is granted **to the named consumer** (and covers
the purpose/resource when known).
- `internal/metrics/metrics.go` — Hand-rolled Prometheus exporter (no client
dependency, like the OTLP encoder). Served from `main.go` on
`CONSENT_METRICS_ADDRESS` when set.
- `internal/logging/logging.go` — Logging front end over the runner's zap logger:
`Redact` fingerprints identifiers, `Sanitize` strips error bodies, and the
`*Every` variants rate-limit a repeated failure to one line per interval.
Nothing in the plugin calls `log.Printf` directly.
- `internal/audit/audit.go` — Access-decision audit emitter: one OTLP/HTTP log
record per decision to the OTel Collector (`service.name=consent-access-audit`
for routing). Async, batched, best-effort; gated by `audit_enabled` +
`audit_otlp_endpoint`. `ResponseFilter` → `recordAudit` calls it.
- `go.mod` — Module path: `consent-plugin`.
Loading
Loading