An Apache APISIX Go plugin that gates access to personal data on the consent of the data subject. It hooks into the APISIX request/response lifecycle via the go-plugin-runner and verifies consent against a Prometheus-X / Visions consent-manager before letting a response reach the client.
The plugin is attached to a route in both external-plugin phases:
- Request phase (
ext-plugin-pre-req→RequestFilter): captures the request context — method, path, and the JWT claims decoded from the configured header — and stores it keyed by the Nginx$request_id. - Response phase (
ext-plugin-post-resp→ResponseFilter): loads that context, asks the OwnerResolver who owns the data in the upstream response, and runs a two-call consent check per resolved owner:- allow (every resolved owner has a granted consent for this consumer) — the response passes through unchanged.
- deny (any owner has no such consent, or is unknown to the consent-manager) — the response is replaced with a configurable error status and body.
The decision is a coarse allow/deny and is independent of the response body's shape, so an empty or non-JSON personal-data response is still gated.
The payload is described to the resolver in one of three ways — json (parsed
and carried), none (there was no payload), and opaque (there was one but it
could not be parsed, sent with its declared content type and size). The last two
are deliberately distinct: an unreadable personal-data payload must not look to
the resolver like no payload at all.
The
$request_idcorrelation is required:ext-plugin-pre-reqandext-plugin-post-respare separate RPCs to the runner and do not share the runner's per-callID().
owner_resolver_url is required. The access token's sub says who is
asking, not whose data is being returned — checking the caller's own consent
would let any subject holding a single granted consent read everyone else's
data. So the data owner is always derived from the response payload by the
OwnerResolver, and the token's claims are used only to name the consuming
participant for the contract lookup and to scope the consent match.
The plugin does not verify the JWT signature. It decodes the claims and assumes an authentication plugin earlier in the route has already validated the token. Attaching
consent-filterto a route with no authentication in front of it is a misconfiguration: the consumer identity would be attacker-supplied.
ext-plugin-post-resp forces APISIX to buffer the entire upstream response
before the runner sees it (ReadBody() is a blocking extra-info RPC over the
unix socket). Gated routes therefore do not stream, and a large response is a
memory multiplier across APISIX and the runner. Keep gated routes to bounded
responses, and note that response_phase_timeout, max_owners_per_response and
max_resolve_body_bytes (below) bound how long the response is held, how many
owners are checked, and how large a payload is forwarded to the resolver.
Per-owner consent checks run concurrently (up to 8 in flight) and short-circuit on the first denial, so latency is not the sum over owners.
The consent-manager has no single "is there consent?" endpoint, so a check is two calls ({consent_api_url}{consent_api_prefix} is the base, e.g. http://consent-manager:3000/v1):
1. Resolve the owner to a user identifier — authenticated with the consent key:
POST {base}/users/identifier/search
Header: x-visionstrust-consent-key: <consent_key>
Body: { "selfDescription": "<provider_sd>", "email": "<owner DID>" }
→ { "userIdentifier": "<id>" } (404 / empty ⇒ unknown owner ⇒ deny)
2. List that user's consents — authenticated with the participant JWT:
GET {base}/consents/participants/{userIdentifier}?receipt=true
Header: Authorization: Bearer <participant_token>
→ { "consents": [ { "status": "granted", "consumer": {...}, "purposes": [...], "data": [...] } ] }
Access is allowed only if a returned consent satisfies all of:
status == "granted";- it was granted to the consuming participant identified from the token (a consent names one consumer; one granted to X is not authority for Y);
- it covers the purpose, when the resolver named one for the claim (set
require_purposeto deny rather than proceed unscoped when it does not); - it covers the data resource, when the resolver scoped the claim to one.
The owner DID is sent as the user email (the consent-manager's
DID-in-email convention).
Call 2 needs a participant JWT, and call 1 needs the provider
self-description. The plugin holds no participant credentials of its own: it
asks the participant-local OID4VP token service (the consent-facade's
POST /internal/tokens) for a short-lived token by audience name, then
derives the provider SD from /participants/me:
POST {token_service_url} { "audience": "<token_audience>" } → { "access_token": ..., "expires_in": ... }
GET {base}/participants/me Authorization: Bearer <token> → { "selfDescriptionURL": ... }
The token is cached and refreshed on expiry or a 401. The cache is keyed on the
full credential identity (base URL, host, prefix, audience, token-service URL,
provider SD, and hashes of the static token and consent key), so two routes
fronting different participants against the same consent-manager never share a
token. A static participant_token and/or an explicit provider_sd remain
supported as overrides for tests and manual runs.
The consumer DID from the token is translated to a self-description URL via
GET {base}/participants (the consent-manager doubles as the participant
registry), cached for 10 minutes — and negatively for 30 seconds, so one
misconfigured DID does not re-fetch the registry on every request.
Configured via the APISIX route plugin JSON (identically on both ext-plugin-pre-req and ext-plugin-post-resp):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
consent_api_url |
string |
Yes | — | Base URL of the consent-manager (e.g. http://consent-manager:3000). http/https only. |
owner_resolver_url |
string |
Yes | — | The OwnerResolver /resolve endpoint. The data owner is resolved from the response payload; without it the plugin cannot determine whose consent to check, so the route fails to load. http/https only. |
owner_resolver_timeout |
int |
No | 2000 |
Per-call timeout in ms for /resolve. Range 1–60000. |
service |
string |
No | — | Logical dataset id sent to the OwnerResolver as resource.service, so it can select the rule for this route. |
response_phase_timeout |
int |
No | 10000 |
Budget in ms for the entire response phase — party lookups, /resolve, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. |
require_purpose |
bool |
No | false |
Deny when a resolved claim names no processing purpose. Purpose matching depends on the OwnerResolver populating an optional field, so a resolver that never sets it runs with purpose scoping silently disabled; turn this on once yours emits one. consent_purpose_unconstrained_total counts the checks it would deny. |
max_resolve_body_bytes |
int |
No | 1048576 |
Maximum upstream body forwarded to the OwnerResolver. A larger body is denied rather than copied — the body is held whole, validated and marshalled again, so the peak footprint is ~3× its size per in-flight request on top of APISIX's own buffering. Range 1–104857600. |
max_owners_per_response |
int |
No | 50 |
Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. |
consumer_claim |
string |
No | verifiableCredential.issuer |
Dotted claim path naming the consuming participant. Supports array indexing (verifiableCredential[0].issuer), and a bare segment landing on an array traverses its first element — a Verifiable Presentation routinely carries verifiableCredential as an array. Used for the contract lookup and to scope the consent match — never for ownership. |
consent_api_prefix |
string |
No | /v1 |
API prefix prepended to endpoint paths (the consent-manager's API_PREFIX). Must start with /; a trailing / is trimmed. |
consent_api_host |
string |
No | — | Overrides the HTTP Host header on consent-manager calls. Needed when consent_api_url points at an in-cluster service whose gateway route is host-scoped to the public ingress name. |
consent_api_timeout |
int |
No | 5000 |
Per-call timeout in ms. Range 1–60000. |
consent_key |
string |
No | — | Shared secret sent as x-visionstrust-consent-key on call 1. Optional: behind the authority's facade the key is injected server-side (and overrides anything sent here). Falls back to CONSENT_KEY. |
token_service_url |
string |
Yes* | — | The participant-local OID4VP token service (the consent-facade's POST /internal/tokens). Falls back to CONSENT_TOKEN_SERVICE_URL. http/https only. |
token_audience |
string |
No | consent-manager |
Audience name asked of the token service — its own configured target, not a URL. |
participant_token_ttl |
int |
No | 3000 |
Seconds a fetched token is cached. The token service's own expires_in wins when shorter. Range 1–86400. |
participant_token |
string |
Yes* | — | Static, pre-obtained participant token. An override for tests and manual runs; prefer token_service_url, which refreshes automatically. |
provider_sd |
string |
No | — | Provider self-description URL for call 1. Derived from /participants/me when unset. |
jwt_header_name |
string |
No | Authorization |
Header carrying the JWT. |
jwt_claims_to_forward |
[]string |
No | [] (all) |
Claims to decode and keep. Empty decodes all. The root of consumer_claim is always added. |
deny_status_code |
int |
No | 403 |
Status returned on deny. Range 100–599. |
deny_response_body |
string |
No | {"error":"access denied by consent policy"} |
Body returned on deny. |
deny_response_content_type |
string |
No | application/json |
Content-Type for deny responses. |
fail_open |
bool |
No | false |
On a dependency failure (resolver or consent-manager down/erroring, unreadable body): false denies, true passes through. Enabling it is logged as a warning at parse time. It does not apply to structural failures — no correlation id, no request context, no participant credentials, or "consent required but no owner resolved" — which always deny. |
audit_enabled |
bool |
No | false |
Emit an access-decision audit event (OTLP/HTTP log) to a Collector for every decision. Async + best-effort; never affects the decision. |
audit_otlp_endpoint |
string |
Yes† | — | Base OTLP/HTTP endpoint of the Collector (e.g. http://otel-collector:4318); /v1/logs is appended. Falls back to CONSENT_AUDIT_OTLP_ENDPOINT. |
audit_otlp_headers |
object |
No | — | Extra HTTP headers sent on every audit export, for a Collector that requires authentication (e.g. {"Authorization":"Bearer ..."}). |
audit_service_name |
string |
No | consent-access-audit |
Resource service.name on audit records — the marker the Collector routes on to keep audit logs separate from traces. |
* Provide either token_service_url (recommended) or a static
participant_token. This is enforced at parse time: a route with neither cannot
authenticate as the participant and so cannot complete a single consent check,
and is rejected rather than loaded.
† Required only when audit_enabled is true.
Credentials via env. consent_key, token_service_url and
audit_otlp_endpoint each fall back to an environment variable (CONSENT_KEY,
CONSENT_TOKEN_SERVICE_URL, CONSENT_AUDIT_OTLP_ENDPOINT) when omitted from the
route config; a value in the config always wins. The plugin runner inherits these
from the APISIX container, which sources them from a Kubernetes Secret — so
secrets need not be stored as plaintext in the route config (etcd).
Metrics. Set the CONSENT_METRICS_ADDRESS environment variable on the
plugin runner (e.g. :9091) to expose Prometheus metrics on /metrics:
consent_decisions_total{decision,fail_mode} (a deny caused by an outage is
labelled apart from one caused by consent),
consent_dependency_calls_total{dependency,outcome},
consent_dependency_duration_seconds{dependency},
consent_request_context_store_size, consent_request_contexts_evicted_total
and consent_audit_events_dropped_total. Metrics are off unless the variable is
set — the runner is otherwise reached only over its unix socket, so opening a
TCP port is the deployment's decision.
Access audit log. With audit_enabled, the plugin emits one OTLP/HTTP log record per checked data owner (so the log answers whose consent was consulted and what each said, not merely whether the response was released; a request that failed before any owner was reached is recorded once as itself) to audit_otlp_endpoint, stamped with resource service.name=<audit_service_name> and attributes event.domain=audit, consent.decision, consent.reason, enduser.id, http.request.method, url.path, http.request.id. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. The queue is flushed when the runner exits (it returns on SIGTERM/SIGINT), so a redeploy does not discard the last flush interval of decisions. Reasons are sanitised before export (control characters collapsed, length bounded) so an upstream error body cannot reach the audit sink verbatim. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces.
# Both phases must carry the SAME configuration, so build it once.
PLUGIN_CONF='{"consent_api_url":"http://consent-manager:3000","consent_api_prefix":"/v1","owner_resolver_url":"http://owner-resolver:8080/resolve","service":"personal-profiles","token_service_url":"http://consent-facade:8080/internal/tokens","token_audience":"consent-manager","fail_open":false}'
jq -n --arg conf "$PLUGIN_CONF" '{
uri: "/*",
host: "data-service.example.org",
upstream: { type: "roundrobin", nodes: { "backend-service:8080": 1 } },
plugins: {
"ext-plugin-pre-req": { conf: [ { name: "consent-filter", value: $conf } ] },
"ext-plugin-post-resp": { conf: [ { name: "consent-filter", value: $conf } ] }
}
}' | curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
-H "X-API-KEY: your-admin-api-key" \
-H "Content-Type: application/json" \
-d @-consent_key is omitted above because the facade injects it; set it (or
CONSENT_KEY) for a facade-less deployment. Both phases are required and must
carry the same configuration: pre-req captures the token context,
post-resp performs the check and blocks the response.
- Go (see
go.modfor the required version) - Docker (for containerized builds)
- golangci-lint (for linting)
make build # build the go-runner binary
make docker-build # build the Docker image
make test # unit + integration tests
make test-cover # tests with coverage
make lint # golangci-lintdocker compose up --buildThe stack is self-contained — APISIX + etcd, the plugin runner, a mock consent-manager / OwnerResolver / token service, an echo upstream, and an OTel Collector for the audit log:
| Service | Description | Ports |
|---|---|---|
etcd |
APISIX configuration store | 2379 |
apisix |
APISIX gateway | 9080 (HTTP), 9180 (Admin API) |
plugin-runner |
consent-filter plugin runner | — (unix socket, shared volume) |
mock |
consent-manager + OwnerResolver + token service stubs (dev/mocks/) |
8081 |
upstream |
echo service standing in for the personal-data API | — |
otel-collector |
receives the access-decision audit log | 4318 |
The runner also serves Prometheus metrics on 9091 (CONSENT_METRICS_ADDRESS is
set for it in the compose file).
Then create the gated route:
PLUGIN_CONF='{"consent_api_url":"http://mock:8080","owner_resolver_url":"http://mock:8080/resolve","token_service_url":"http://mock:8080/internal/tokens","consent_key":"dev-consent-key","service":"dev-profiles","fail_open":false}'
jq -n --arg conf "$PLUGIN_CONF" '{
uri: "/*",
upstream: { type: "roundrobin", nodes: { "upstream:8080": 1 } },
plugins: {
"ext-plugin-pre-req": { conf: [ { name: "consent-filter", value: $conf } ] },
"ext-plugin-post-resp": { conf: [ { name: "consent-filter", value: $conf } ] }
}
}' | curl -s -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
-H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" -H "Content-Type: application/json" -d @-
# The token names the consumer the mock's consent was granted to, so this passes.
TOKEN_PAYLOAD=$(printf '{"verifiableCredential":{"issuer":"did:key:zDevConsumer"}}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -i http://127.0.0.1:9080/profile -H "Authorization: Bearer e30.${TOKEN_PAYLOAD}.nosig"Change the consumer in dev/mocks/consents.json (or the status to
revoked) and restart the mock service to watch the same request be denied.
The otel-collector logs show the audit record for each decision.
The socket is shared through a directory (the
runner-socketvolume), not by bind-mounting the socket file: Docker creates a directory at a bind-mount path that does not exist yet, and the runner then cannot bind.
consent-plugin/
├── main.go # Entry point — registers plugin, starts runner
├── Makefile / Dockerfile / docker-compose.yaml
├── internal/
│ ├── plugin/
│ │ ├── consent.go # RequestFilter + ResponseFilter (the consent gate)
│ │ ├── config.go # Configuration schema and validation
│ │ └── context.go # Bounded request-context store keyed by $request_id
│ ├── consent/
│ │ ├── client.go # Two-call consent-manager client
│ │ └── models.go # Request/response models and Decision type
│ ├── ownerresolver/
│ │ └── client.go # OwnerResolver /resolve client (who owns the data)
│ ├── audit/
│ │ └── audit.go # OTLP/HTTP access-decision audit exporter
│ ├── jwt/
│ │ └── extractor.go # JWT extraction and claim decoding
│ └── integration/
│ └── integration_test.go # End-to-end integration tests
GitHub Actions run the quality gates on every PR and cut releases on merge to
main, following the FIWARE/VCVerifier
structure. See .github/workflows/README.md for the
full pipeline and CONTRIBUTING.md for the label-driven
versioning rules.
Each release publishes:
- a multi-arch (
linux/amd64,arm64) imagequay.io/seamware/consent-plugin:<version>(also:latest,:<sha>), and - standalone
go-runnerbinaries (consent-plugin-linux-{amd64,arm64}) on the GitHub Release.
The image is the primary artifact. In the APISIX deployment an init container
stages the runner binary out of the image into a shared ext-plugin volume, and
APISIX launches it as the external plugin runner:
initContainers:
- name: install-consent-plugin
image: quay.io/seamware/consent-plugin:<version>
command: ["cp", "/app/go-runner", "/ext-plugin/go-runner"]
volumeMounts:
- name: ext-plugin-bin
mountPath: /ext-plugin
# ... APISIX then runs: exec /ext-plugin/go-runnerSee the reference consent-provider.yaml for the complete deployment (secret mounting, env, socket wiring).
Releasing requires the QUAY_USERNAME / QUAY_PASSWORD repository secrets.
Apache-2.0 - see LICENSE. Every Go source file carries the copyright header from
hack/license-header.txt; make license-check verifies it and CI enforces
it on pull requests, on main, and as a gate on the pre-release and release.