An OwnerResolver service for the consent-plugin.
The consent-plugin must decide, for data flowing through it, whether that data needs a consent check and whose data it is — independently of the requestor. It delegates exactly that to this service, so the plugin stays generic and the (provider-/dataset-specific) ownership logic lives here.
The resolver answers two questions about a payload:
- (a) does this data require a consent check? →
consentRequired - (b) who is the data owner (subject)? →
claims[].ownerId
The decision unit is (owner × dataResource): a single owner may have consented to some data objects but not others, so every claim carries both an owner and the resource it concerns. The request carries no caller identity — that independence is structural.
The resolver is data-format agnostic: it handles structured JSON (owner read from a field), and opaque payloads such as a single file (owner derived from the request path, with the whole payload as one claim — the body need not even be sent).
The full contract is api/openapi.json — an OpenAPI 3.0
document, written as JSON so the repository's own tests parse it with the
standard library and fail when a response drifts from it
(internal/api/openapi_test.go). What follows is the readable summary.
Request — the data and its provenance, never the requestor:
encoding: "none"(or nobody) → the resolver decides fromresourcealone (e.g. large or opaque files identified by their path).encoding: "base64"→contentis a JSON string of base64 bytes; decoded to JSON when it happens to be JSON, otherwise treated as opaque.partiesexists only so the governing contract can be identified (a contract is provider↔consumer by definition). It is never used to determine the owner — that always comes from the data. Only thecontractmatcher reads it, and for that matcherparties.consumeris required: a request without it fails422.
Response:
{
"consentRequired": true,
"scheme": "identifier", // how to interpret ownerId: identifier | email | did
"claims": [
{
"selector": { "type": "json-pointer", "value": "/0" }, // or { "type": "whole" }
"ownerId": "alice-42",
"participant": "https://…/provider-sd", // optional lookup scope
"dataResource": "urn:dsc:resource:personal-profile"
}
]
}dataResourceis optional. In v1 it is the id of the requested entity (setresourcePointer: "/id", or a(?P<resource>…)path group), so consent is checked per entity. Omit it entirely for owner-level consent.selector.type:whole(opaque / single object — not redactable) orjson-pointer(an RFC6901 pointer; enables v2 field-level filtering).- On success →
200. On a payload the resolver cannot decode →400. When consent may be required but the owner cannot be determined →422(the plugin then applies its fail policy — deny by default). A resolver response never silently means "no consent needed". - Error bodies are deliberately generic (
{"error":"cannot resolve owner"}): the detail names this deployment's pointer configuration, so it goes to the log instead. Run withLOG_LEVEL=debugto get it.
| metric | type | labels |
|---|---|---|
owner_resolver_requests_total |
counter | route, status |
owner_resolver_request_duration_seconds |
histogram | route |
owner_resolver_resolve_failures_total |
counter | class |
The resolver sits on the synchronous path of every proxied request while fanning
out to the consent-facade, so p99 latency and failure rate are what you will
want the first time the gateway starts timing out. class is the failing
component (json matcher, contract matcher, …), never the error detail, and
comes from a closed set; the labels deliberately carry no request path and no
owner, so /metrics is safe to scrape without the shared secret. It is served
on the same port as /resolve, so a scrape needs its own NetworkPolicy rule —
see Deployment.
Every response carries X-Request-Id. Send one and the resolver reuses it
(when it is short and free of control characters); send none and it mints one.
The same id appears in the failure log line, so a plugin-side trace and a
resolver-side log line can be joined.
JSON, mounted at CONFIG_PATH (default /etc/owner-resolver/config.json).
Rules are evaluated top-down; the first whose match matches wins. A rule with
an empty match matches everything, so it is only allowed as the last rule
— anywhere else it would make every rule below it unreachable, and Parse
rejects that.
{
"defaultConsentRequired": false, // returned when no rule matches (set true to fail closed)
"defaultScheme": "identifier", // identifier | email | did
"contractService": { // required only when a `contract` matcher is used
"url": "http://consent-facade:8080",
"providerSelfDescription": "https://…/participants/urn:ngsi-ld:organization:prov",
"timeoutMs": 3000, // per facade call (default 3000)
"resourceCacheTtlMs": 30000 // catalog cache (default 30000; negative disables)
},
"rules": [
{
"name": "ngsi-personal-profiles",
"match": { "service": "mp-data-service", "pathPattern": "" }, // both optional
"consentRequired": true,
"matcher": { "type": "json", "items": "", "itemsIsArray": false,
"owner": "/dataOwner", "resource": "urn:dsc:resource:personal-profile" }
},
{
"name": "opaque-files",
"match": { "service": "file-service" },
"consentRequired": true,
"matcher": { "type": "path", "pattern": "^/files/(?P<owner>[^/]+)/(?P<resource>.+)$" }
},
{
"name": "ngsi-from-contract",
"match": { "service": "mp-data-service" },
"consentRequired": true,
"matcher": { "type": "contract", "items": "", "itemsIsArray": false,
"owner": "/dataOwner/value", "uriPointer": "/id" }
}
]
}Points at a consent-facade (a provider-local instance) and is required as
soon as any rule uses the contract matcher; Parse fails without url.
| field | meaning |
|---|---|
url |
facade base url, e.g. http://consent-facade:8080. Required. |
providerSelfDescription |
this provider's participant SD url — one side of every contract lookup. May be omitted and supplied per request as parties.provider; with neither, a contract rule fails 422. |
timeoutMs |
bounds each facade call. Default 3000. |
resourceCacheTtlMs |
how long a contract's catalog data resources are reused across requests. Default 30000; negative disables the cache. Contracts themselves are never cached — they carry the signature state. |
| type | data | how it finds the owner | selector emitted |
|---|---|---|---|
json |
structured JSON | owner = RFC6901 pointer within each item; items+itemsIsArray iterate a collection (multi-subject); resource fixed or resourcePointer |
json-pointer |
path |
anything (incl. opaque files) | regexp pattern with named groups (?P<owner>…) and optional (?P<resource>…); needs no body |
whole |
static |
any | fixed owner/resource (tests, always-gated routes) |
whole |
contract |
structured JSON | owner = RFC6901 pointer within each item (as json); the dataResource comes from the governing contract instead of config — see below |
json-pointer |
The owner still comes from the data; what the contract adds is which data resource the claim is about, in the catalog's own vocabulary.
parties.consumer(required) plus the provider SD identify the signed contracts at the facade — onlystatus: "signed"counts.- The requested object's URI (read from
uriPointerwithin each item) is matched against each contract policy's ODRLassetTarget. A contract that prohibits that URI in any of its policies never governs it, even if another of its policies permits it — a grant and a denial split across two policies of one agreement is not a grant. Another signed contract that permits the URI can still govern it. - The governing contract's service offering is dereferenced, and its first
catalog resource with
containsPII: truebecomes the claim'sdataResource. A contract that declares no PII resource yields an owner-level claim (nodataResource) rather than silently allowing.
| option | meaning |
|---|---|
owner |
RFC6901 pointer to the owner within each item. Required. |
uriPointer |
RFC6901 pointer to the requested object's URI within each item. Default /id (NGSI-LD and most JSON-LD payloads). |
items |
RFC6901 pointer to the collection/item root; "" = the whole body. |
itemsIsArray |
true → each element of items yields its own claim. |
participant |
optional lookup scope, copied onto every claim. |
consentRequired is still the rule's own flag: containsPII selects which
resource the claim names, it does not decide whether consent is checked.
Every failure here is fail-closed: no consumer, no provider, no signed
contract, no contract targeting the object, or no owner in the data all produce
422.
dataResource values MUST use the same vocabulary the privacy notice / consent
are expressed in (Consent.data[].resource) — that shared taxonomy is the real
integration contract.
make build && CONFIG_PATH=config/example.json ./owner-resolver # :8080
make test # unit tests
make lint # golangci-lint, .golangci.yml
make security # govulncheck + gosec (blocking in CI)
make docker-build # quay.io/seamware/consent-owner-resolver:0.0.1Requires Go 1.26.7+ (the version go.mod pins). The runtime image is
gcr.io/distroless/static-debian12:nonroot — no shell, no package manager, and
nothing to patch beyond the binary itself.
Env: CONFIG_PATH, LISTEN_ADDR (default :8080), MAX_BODY_BYTES (default 5 MiB),
AUTH_TOKEN (see below), LOG_LEVEL (debug to log request paths and error
detail verbatim — both carry owner identifiers, so it is off by default and
meant to be temporary).
/resolve must not be reachable from outside the cluster. It answers who
owns this data, so an open port is an owner-identifier oracle: a caller can
probe which services and paths are gated, harvest owner ids (a path-matcher
route returns ownerId from the path with no body at all), and enumerate which
contracts exist between arbitrary provider/consumer pairs by varying parties.
Restrict it to the plugin — and to Prometheus, which needs /metrics on the
same port — with a NetworkPolicy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: owner-resolver-plugin-only
spec:
podSelector:
matchLabels: { app: owner-resolver }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: apisix } # the consent-plugin's pod
ports:
- protocol: TCP
port: 8080
# /metrics is on the same port, so a scrape needs its own rule. Drop this
# rule if you do not scrape the resolver; ingress is deny-by-default, so
# without it Prometheus simply cannot reach it.
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring }
ports:
- protocol: TCP
port: 8080A NetworkPolicy admits whole ports, not paths, so the monitoring rule also
reaches /resolve. That is what AUTH_TOKEN is for: with it set, the scrape
still works (/metrics is exempt) while /resolve needs the secret even from
inside the cluster.
Where the plugin and the resolver do not share a trust boundary, set
AUTH_TOKEN on the resolver and have the plugin send
Authorization: Bearer <token>; requests without it get 401. GET /health
stays unauthenticated so liveness probes keep working. mTLS between the two (a
service mesh, or a sidecar) is the stronger option where one is available.
consumer ─▶ APISIX ─▶ upstream ─▶ [consent-plugin] ──POST /resolve──▶ [owner-resolver]
│ ◀── consentRequired, claims[] (owner × resource) ──┘
└── per (owner × dataResource): consent check ─▶ consent-manager
This repo is only the resolver. The plugin-side integration (calling /resolve,
then checking consent per resolved owner) and the consent-manager changes are
tracked separately.
Every PR needs exactly one semver label (patch / minor / major) — the
merge to main reads it to compute the release. See
CONTRIBUTING.md for that and the code rules, and
SECURITY.md for reporting a vulnerability (privately, not as an
issue).
Every Go source file carries the Apache-2.0 copyright header. The canonical text lives in
hack/license-header.txt - edit it there and nowhere else.
make license-check # verify (what CI runs)
make license-fix # add the header to files that lack itCI enforces this on every pull request and on every push to main
(.github/workflows/license-headers.yml), so a new file without the header fails the build. The
check covers *.go only: the header is a /* */ block, which is not valid comment syntax in the
Dockerfile or the Makefile.
{ "resource": { "service": "mp-data-service", // logical dataset id (set on the plugin route) "method": "GET", "path": "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:alice", "contentType": "application/ld+json" }, "parties": { // optional; identifies the CONTRACT, never the owner "consumer": "did:web:fancy-marketplace.biz", // the requesting participant "provider": "https://…/participants/urn:…:prov" // optional override of the configured provider SD }, "body": { // optional "encoding": "json", // json | base64 | none "content": { /* payload, per encoding */ } } }