diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 12d2623..468433d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,5 +1,6 @@ # CI pipeline for consent-plugin. -# Runs on every push and pull request: lint, test with coverage, and build. +# Runs on every push and pull request: copyright headers, lint, test with +# coverage, and build. name: CI @@ -23,6 +24,9 @@ jobs: with: go-version-file: go.mod + - name: Verify copyright headers + run: ./hack/license-header.sh check + - name: Install golangci-lint uses: golangci/golangci-lint-action@v6 with: diff --git a/.github/workflows/license-headers.yml b/.github/workflows/license-headers.yml new file mode 100644 index 0000000..2c0b1e4 --- /dev/null +++ b/.github/workflows/license-headers.yml @@ -0,0 +1,16 @@ +name: License Headers + +# Reusable check: every Go source file must carry the Apache-2.0 copyright +# header. The header text and the check live in hack/, so this workflow, the +# `make license-check` target and a local run share one source of truth. +on: + workflow_call: + +jobs: + license-headers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify copyright headers + run: ./hack/license-header.sh check diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bfea29a..94f0dd9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,9 @@ permissions: security-events: write jobs: + license-headers: + uses: ./.github/workflows/license-headers.yml + style-guide: uses: ./.github/workflows/style-guide.yml @@ -42,6 +45,7 @@ jobs: release: needs: + - license-headers - style-guide - build - tests diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fe8a7b8..55213f6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,6 +19,9 @@ permissions: security-events: write jobs: + license-headers: + uses: ./.github/workflows/license-headers.yml + style-guide: uses: ./.github/workflows/style-guide.yml diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 87c3d51..6af593c 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -24,7 +24,13 @@ env: IMAGE_NAME: consent-plugin jobs: + # The copyright headers gate the pre-release too, so an unheadered file cannot + # be published even as a PRE image. + license-headers: + uses: ./.github/workflows/license-headers.yml + generate-version: + needs: license-headers runs-on: ubuntu-latest outputs: version: ${{ steps.out.outputs.version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ccbc64..db6795f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,11 +31,27 @@ pre-release so the change can be deployed and tested before merge. Merging without a semver label runs the pipeline but produces no release. +## Copyright headers + +Every Go source file must carry the Apache-2.0 copyright header. The canonical text lives in +[`hack/license-header.txt`](hack/license-header.txt) - edit it there and nowhere else. + +```bash +make license-check # verify (what CI runs) +make license-fix # add the header to files that lack it +``` + +CI enforces this on pull requests and on pushes to `main`, and gates both the pre-release and the +release on it, so a version can never ship a file without the header. The Gitea pipeline +(`.gitea/workflows/ci.yaml`) runs the same check. It covers `*.go` only: the header is a `/* */` +block, which is not valid comment syntax in the Dockerfile or the Makefile. + ## Local checks Run the same gates locally before opening a PR: ```bash +make license-check # copyright headers make lint # golangci-lint make test # unit + integration tests (race) make build # compile the go-runner diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 13008e4..d514548 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ GO_BUILD_FLAGS := -trimpath -ldflags="-s -w" # Coverage output file COVERAGE_FILE := coverage.out -.PHONY: build test test-cover lint docker-build clean +.PHONY: build test test-cover lint license-check license-fix docker-build clean ## build: Compile the go-runner binary build: @@ -32,6 +32,14 @@ test-cover: lint: golangci-lint run ./... +## license-check: Verify the Apache-2.0 copyright header on every Go file (CI runs this) +license-check: + ./hack/license-header.sh check + +## license-fix: Add the copyright header to Go files that lack it +license-fix: + ./hack/license-header.sh fix + ## docker-build: Build the Docker image docker-build: docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) . diff --git a/README.md b/README.md index fa08051..9dc1bc5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ GET {base}/consents/participants/{userIdentifier}?receipt=true Header: Authorization: Bearer → { "consents": [ { "status": "granted" | "revoked" | ... } ] } ``` -Access is **allowed** iff at least one returned consent has `status == "granted"`. +Access is **allowed** if at least one returned consent has `status == "granted"`. The subject DID is taken from the JWT `sub` claim (so `jwt_claims_to_forward` must include `sub`) and sent as the user `email` (the consent-manager's DID-in-email convention). @@ -190,4 +190,6 @@ Releasing requires the `QUAY_USERNAME` / `QUAY_PASSWORD` repository secrets. ## License -See [LICENSE](LICENSE) for details. +Apache-2.0 - see [LICENSE](LICENSE). Every Go source file carries the copyright header from +[`hack/license-header.txt`](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. diff --git a/hack/license-header.sh b/hack/license-header.sh new file mode 100755 index 0000000..e54177f --- /dev/null +++ b/hack/license-header.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Verifies (or adds) the Apache-2.0 copyright header on every Go source file. +# +# hack/license-header.sh check # default; exits 1 and lists files that lack it +# hack/license-header.sh fix # prepends the header where it is missing +# +# The header text lives in hack/license-header.txt so this script, the `make` +# targets and CI all share one source of truth - editing the header in one place +# is enough. +# +# Only *.go files are checked: the header is a /* */ block, which is not valid +# comment syntax in the Dockerfile or the Makefile. +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly HEADER_FILE="${REPO_ROOT}/hack/license-header.txt" +readonly MODE="${1:-check}" + +if [[ ! -f "${HEADER_FILE}" ]]; then + echo "error: header template not found at ${HEADER_FILE}" >&2 + exit 2 +fi + +readonly HEADER_LINES="$(wc -l < "${HEADER_FILE}" | tr -d '[:space:]')" + +# All Go sources, excluding vendored and generated trees. +collect_files() { + find "${REPO_ROOT}" \ + -type d \( -name vendor -o -name .git -o -name node_modules \) -prune -o \ + -type f -name '*.go' -print | sort +} + +# True when the file already starts with the exact header. +has_header() { + local file="$1" + diff -q <(head -n "${HEADER_LINES}" "${file}") "${HEADER_FILE}" >/dev/null 2>&1 +} + +# Prepends the header plus a blank line. The blank line matters: without it the +# license block would become the package's doc comment (`go doc` would print the +# licence instead of the package documentation). +add_header() { + local file="$1" tmp + tmp="$(mktemp)" + cat "${HEADER_FILE}" > "${tmp}" + printf '\n' >> "${tmp}" + cat "${file}" >> "${tmp}" + mv "${tmp}" "${file}" +} + +missing=() +while IFS= read -r file; do + has_header "${file}" || missing+=("${file}") +done < <(collect_files) + +case "${MODE}" in + check) + if (( ${#missing[@]} > 0 )); then + echo "The Apache-2.0 copyright header is missing from ${#missing[@]} file(s):" >&2 + for file in "${missing[@]}"; do + echo " ${file#"${REPO_ROOT}"/}" >&2 + done + echo >&2 + echo "Run 'make license-fix' (or hack/license-header.sh fix) to add it." >&2 + exit 1 + fi + echo "copyright header present on all Go files" + ;; + fix) + if (( ${#missing[@]} == 0 )); then + echo "copyright header already present on all Go files" + exit 0 + fi + for file in "${missing[@]}"; do + add_header "${file}" + echo "added header: ${file#"${REPO_ROOT}"/}" + done + ;; + *) + echo "usage: $(basename "$0") [check|fix]" >&2 + exit 2 + ;; +esac diff --git a/hack/license-header.txt b/hack/license-header.txt new file mode 100644 index 0000000..4da7b57 --- /dev/null +++ b/hack/license-header.txt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 424250d..a8356b4 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package audit emits access-decision events from the consent-filter plugin to // an OpenTelemetry Collector as OTLP/HTTP log records. Each record carries a // dedicated resource service.name so the Collector can route audit logs to a diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index a421baa..75035a2 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package audit import ( diff --git a/internal/consent/client.go b/internal/consent/client.go index 3a03b5c..fab858e 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package consent import ( @@ -28,12 +45,14 @@ const ( // the participant. Authenticated with the participant JWT. %s = identifier. participantConsentsPathFmt = "/consents/participants/%s" - // participantLoginPath exchanges client credentials for a participant JWT. - participantLoginPath = "/participants/login" - // participantMePath returns the calling participant (incl. selfDescriptionURL). participantMePath = "/participants/me" + // participantsPath lists the known participants (the consent-manager is the + // participant registry), used to map a participant DID to its + // self-description URL. + participantsPath = "/participants" + // consentKeyHeader carries the shared consent key on the identifier search. consentKeyHeader = "x-visionstrust-consent-key" @@ -51,6 +70,10 @@ const ( // tokens, so the default keeps a safety margin. DefaultTokenTTL = 50 * time.Minute + // tokenRefreshSkew is subtracted from a token's cached lifetime so it is never + // presented in the last moments before it expires. + tokenRefreshSkew = 60 * time.Second + // ContentTypeJSON is the Content-Type used for JSON request bodies. ContentTypeJSON = "application/json" ) @@ -64,21 +87,25 @@ var errParticipantUnauthorized = errors.New("consent client: participant token u type ClientConfig struct { // BaseURL is the consent-manager root (e.g. "http://consent-manager:3000"). BaseURL string + // Host, if set, overrides the HTTP Host header on every request (the URL host + // is still used for the connection). Needed for host-scoped gateway routes. + Host string // APIPrefix is prepended to endpoint paths (defaults to DefaultAPIPrefix). APIPrefix string // ConsentKey is the shared secret sent on the identifier search (required). ConsentKey string // ProviderSD, if empty, is derived from GET /participants/me after login. ProviderSD string - // ParticipantToken, if set, is used as a static token (no login is done). - // Otherwise ClientID/ClientSecret are exchanged for a token. + // ParticipantToken, if set, is used as a static token (nothing is fetched). + // Otherwise a token is obtained from TokenServiceURL. ParticipantToken string - // ClientID / ClientSecret are the participant client credentials used to - // obtain (and refresh) a participant token via /participants/login. - ClientID string - ClientSecret string - // TokenTTL is how long a client-credentials token is cached (defaults to - // DefaultTokenTTL). + // TokenServiceURL is the participant-local OID4VP token service that mints an + // access token for this participant (the consent-facade's /internal/tokens). + TokenServiceURL string + // TokenAudience is the audience name asked of the token service. + TokenAudience string + // TokenTTL caps how long a fetched token is cached (defaults to + // DefaultTokenTTL); the token service's own expiry wins when shorter. TokenTTL time.Duration // TimeoutMs is the per-call HTTP timeout (defaults to DefaultTimeoutMs). TimeoutMs int @@ -93,21 +120,24 @@ type ClientConfig struct { // 2. GET {base}/consents/participants/{id}?receipt=true — list that user's // consents, authenticated with the participant JWT. // -// The participant JWT and (optionally) the provider self-description are obtained -// via client credentials: POST /participants/login exchanges ClientID/ClientSecret -// for a token, and GET /participants/me yields the provider selfDescriptionURL. -// Tokens are cached package-wide (keyed by base URL + client id) and refreshed on -// expiry or a 401. Access is allowed iff a returned consent is "granted". +// The access token is obtained from the participant-local OID4VP token service +// (POST {TokenServiceURL} with the configured audience), so this plugin holds no +// participant credentials of its own: the token service presents the +// participant's verifiable credential and returns a short-lived token. GET +// /participants/me then yields the provider selfDescriptionURL. Tokens are cached +// package-wide (keyed by base URL + audience) and refreshed on expiry or a 401. +// Access is allowed iff a returned consent is "granted". type Client struct { - baseURL string - apiPrefix string - consentKey string - providerSD string - staticToken string - clientID string - clientSecret string - tokenTTL time.Duration - httpClient *http.Client + baseURL string + host string + apiPrefix string + consentKey string + providerSD string + staticToken string + tokenServiceURL string + tokenAudience string + tokenTTL time.Duration + httpClient *http.Client } // NewClient creates a consent-manager client from cfg, applying defaults for @@ -126,15 +156,16 @@ func NewClient(cfg ClientConfig) *Client { ttl = DefaultTokenTTL } return &Client{ - baseURL: cfg.BaseURL, - apiPrefix: prefix, - consentKey: cfg.ConsentKey, - providerSD: cfg.ProviderSD, - staticToken: cfg.ParticipantToken, - clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret, - tokenTTL: ttl, - httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}, + baseURL: cfg.BaseURL, + host: cfg.Host, + apiPrefix: prefix, + consentKey: cfg.ConsentKey, + providerSD: cfg.ProviderSD, + staticToken: cfg.ParticipantToken, + tokenServiceURL: cfg.TokenServiceURL, + tokenAudience: cfg.TokenAudience, + tokenTTL: ttl, + httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}, } } @@ -156,7 +187,7 @@ var ( credCache = map[string]*cacheEntry{} ) -func (c *Client) cacheKey() string { return c.baseURL + "|" + c.clientID } +func (c *Client) cacheKey() string { return c.baseURL + "|" + c.tokenAudience } // CheckConsent runs the two-call consent verification for req.Subject, allowing // when a granted consent exists and denying otherwise. An unknown subject is a @@ -168,10 +199,10 @@ func (c *Client) CheckConsent(ctx context.Context, req ConsentRequest) (*Consent return &ConsentResponse{Decision: DecisionDeny, Reason: "no subject in request"}, nil } - resp, err := c.check(ctx, req.Subject, false) + resp, err := c.check(ctx, req.Subject, req.DataResource, false) if errors.Is(err, errParticipantUnauthorized) && c.staticToken == "" { // The cached token was rejected — refresh it and retry once. - resp, err = c.check(ctx, req.Subject, true) + resp, err = c.check(ctx, req.Subject, req.DataResource, true) } if errors.Is(err, errParticipantUnauthorized) { // Still unauthorized (or a static token was rejected): surface a plain error. @@ -181,8 +212,9 @@ func (c *Client) CheckConsent(ctx context.Context, req ConsentRequest) (*Consent } // check performs one full verification attempt. forceLogin refreshes a cached -// client-credentials token before use. -func (c *Client) check(ctx context.Context, subject string, forceLogin bool) (*ConsentResponse, error) { +// client-credentials token before use. When dataResource is non-empty the check +// is scoped: a granted consent counts only if it covers that resource. +func (c *Client) check(ctx context.Context, subject, dataResource string, forceLogin bool) (*ConsentResponse, error) { token, providerSD, err := c.credentials(ctx, forceLogin) if err != nil { return nil, err @@ -196,31 +228,34 @@ func (c *Client) check(ctx context.Context, subject string, forceLogin bool) (*C return &ConsentResponse{Decision: DecisionDeny, Reason: "no user identifier for subject"}, nil } - granted, err := c.hasGrantedConsent(ctx, token, userIdentifier) + granted, err := c.hasGrantedConsent(ctx, token, userIdentifier, dataResource) if err != nil { return nil, err } if granted { return &ConsentResponse{Decision: DecisionAllow}, nil } + if dataResource != "" { + return &ConsentResponse{Decision: DecisionDeny, Reason: "no granted consent for resource " + dataResource}, nil + } return &ConsentResponse{Decision: DecisionDeny, Reason: "no granted consent"}, nil } // credentials resolves the participant token and provider self-description, // preferring static configuration and otherwise using the client-credentials -// login (cached) and GET /participants/me. forceLogin bypasses a cached token. +// the token service (cached) and GET /participants/me. forceFetch bypasses a cached token. // // The global map lock is held only long enough to get-or-create this key's cache // entry; the login/me HTTP calls run under the entry's own lock. So a refresh for // one participant never blocks cache hits (or refreshes) for another, and -// concurrent first requests for the same participant coalesce onto one login. -func (c *Client) credentials(ctx context.Context, forceLogin bool) (token, providerSD string, err error) { +// concurrent first requests for the same participant coalesce onto one fetch. +func (c *Client) credentials(ctx context.Context, forceFetch bool) (token, providerSD string, err error) { // Fully static: no cache or HTTP needed. if c.staticToken != "" && c.providerSD != "" { return c.staticToken, c.providerSD, nil } - if c.staticToken == "" && (c.clientID == "" || c.clientSecret == "") { - return "", "", fmt.Errorf("consent client: no participant_token and no client_id/client_secret configured") + if c.staticToken == "" && c.tokenServiceURL == "" { + return "", "", fmt.Errorf("consent client: no participant_token and no token_service_url configured") } // Get-or-create the per-key entry under the map lock (brief), then release it @@ -241,16 +276,16 @@ func (c *Client) credentials(ctx context.Context, forceLogin bool) (token, provi // Participant token: static override, or a cached/refreshed login token. token = c.staticToken if token == "" { - if forceLogin { + if forceFetch { entry.token = "" } if entry.token == "" || time.Now().After(entry.tokenExpiry) { - jwt, lerr := c.login(ctx) + fetched, lifetime, lerr := c.fetchToken(ctx) if lerr != nil { return "", "", lerr } - entry.token = jwt - entry.tokenExpiry = time.Now().Add(c.tokenTTL) + entry.token = fetched + entry.tokenExpiry = time.Now().Add(cacheFor(lifetime, c.tokenTTL)) } token = entry.token } @@ -271,40 +306,184 @@ func (c *Client) credentials(ctx context.Context, forceLogin bool) (token, provi return token, providerSD, nil } -// loginResponse is the consent-manager response to POST /participants/login. -type loginResponse struct { - Success bool `json:"success"` - JWT string `json:"jwt"` +// participantSDCacheTTL bounds how long a DID -> self-description mapping is +// reused. Participants change rarely, so a generous TTL keeps the registry call +// off the request path. +const participantSDCacheTTL = 10 * time.Minute + +type participantSDEntry struct { + selfDescriptionURL string + expiry time.Time +} + +var ( + participantSDMu sync.Mutex + participantSDCache = map[string]participantSDEntry{} +) + +// participantListEntry is the (subset of the) consent-manager participant record. +type participantListEntry struct { + DID string `json:"did"` + SelfDescriptionURL string `json:"selfDescriptionURL"` } -// login exchanges the client credentials for a participant token. -func (c *Client) login(ctx context.Context) (string, error) { - payload, err := json.Marshal(map[string]string{"clientID": c.clientID, "clientSecret": c.clientSecret}) +// participantsResponse tolerates both a bare array and a wrapped list. +type participantsResponse struct { + Participants []participantListEntry `json:"participants"` +} + +// ParticipantSelfDescriptionByDID maps a participant DID to its self-description +// URL using the consent-manager's participant registry. Contracts name their +// parties by self-description URL, so a DID taken from a credential must be +// translated before it can be used in a contract lookup. Results are cached for +// participantSDCacheTTL. +func (c *Client) ParticipantSelfDescriptionByDID(ctx context.Context, did string) (string, error) { + if did == "" { + return "", fmt.Errorf("consent client: empty participant did") + } + cacheKey := c.baseURL + "|" + did + + participantSDMu.Lock() + entry, hit := participantSDCache[cacheKey] + participantSDMu.Unlock() + if hit && time.Now().Before(entry.expiry) { + return entry.selfDescriptionURL, nil + } + + token, _, err := c.credentials(ctx, false) if err != nil { - return "", fmt.Errorf("consent client: failed to marshal login request: %w", err) + return "", err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(participantLoginPath), bytes.NewReader(payload)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint(participantsPath), nil) if err != nil { - return "", fmt.Errorf("consent client: failed to create login request: %w", err) + return "", fmt.Errorf("consent client: failed to create participants request: %w", err) } - httpReq.Header.Set("Content-Type", ContentTypeJSON) + httpReq.Header.Set("Authorization", "Bearer "+token) status, body, err := c.do(httpReq) if err != nil { return "", err } + if status == http.StatusUnauthorized { + return "", errParticipantUnauthorized + } if status != http.StatusOK { - return "", fmt.Errorf("consent client: participant login returned status %d, body: %s", - status, truncateBody(body)) + return "", fmt.Errorf("consent client: participants lookup returned status %d, body: %s", status, truncateBody(body)) + } + + participants, err := decodeParticipants(body) + if err != nil { + return "", err + } + for _, p := range participants { + if p.DID == did && p.SelfDescriptionURL != "" { + participantSDMu.Lock() + participantSDCache[cacheKey] = participantSDEntry{selfDescriptionURL: p.SelfDescriptionURL, expiry: time.Now().Add(participantSDCacheTTL)} + participantSDMu.Unlock() + return p.SelfDescriptionURL, nil + } + } + return "", fmt.Errorf("consent client: no participant registered for did %q", did) +} + +// decodeParticipants accepts either a bare array or a {"participants": [...]} +// wrapper, since the consent-manager has used both shapes. +func decodeParticipants(body []byte) ([]participantListEntry, error) { + var bare []participantListEntry + if err := json.Unmarshal(body, &bare); err == nil { + return bare, nil } - var out loginResponse + var wrapped participantsResponse + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, fmt.Errorf("consent client: failed to unmarshal participants response: %w", err) + } + return wrapped.Participants, nil +} + +// ProviderSelfDescription returns this participant's self-description URL - the +// static override when configured, otherwise the value derived from +// GET /participants/me (cached with the participant token). Callers use it to +// name the provider side of a contract lookup. +func (c *Client) ProviderSelfDescription(ctx context.Context) (string, error) { + _, providerSD, err := c.credentials(ctx, false) + if err != nil { + return "", err + } + return providerSD, nil +} + +// loginResponse is the consent-manager response to POST /participants/login. +// tokenResponse is the OAuth2-shaped reply of the participant-local token service. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +// tokenRequest asks the token service for a token for one configured audience. +// The audience is a NAME the token service resolves against its own configuration, +// never a URL - a caller that could name an arbitrary host would make the token +// service present this participant's credential to it. +type tokenRequest struct { + Audience string `json:"audience"` +} + +// cacheFor returns how long a fetched token may be cached: the shorter of the +// lifetime the token service reported and the configured cap, less a small skew +// so a token is never presented in the last moments of its life. A service that +// reports no usable lifetime falls back to the cap. +func cacheFor(lifetime, maxLifetime time.Duration) time.Duration { + ttl := maxLifetime + if lifetime > 0 && lifetime < maxLifetime { + ttl = lifetime + } + if ttl > tokenRefreshSkew { + return ttl - tokenRefreshSkew + } + return ttl +} + +// fetchToken asks the participant-local token service for an access token, +// returning the token and the lifetime it reported. +// +// The plugin fails closed, so the returned error names the status: the token +// service answers 502 when the verifier could not be reached (retryable) and +// 403/400/500 when the credential was refused or it is misconfigured (terminal). +func (c *Client) fetchToken(ctx context.Context) (string, time.Duration, error) { + payload, err := json.Marshal(tokenRequest{Audience: c.tokenAudience}) + if err != nil { + return "", 0, fmt.Errorf("consent client: failed to marshal token request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenServiceURL, bytes.NewReader(payload)) + if err != nil { + return "", 0, fmt.Errorf("consent client: failed to create token request: %w", err) + } + httpReq.Header.Set("Content-Type", ContentTypeJSON) + + // Deliberately not c.do(): the token service is a local, same-namespace + // service, not the consent-manager, so the Host override and API prefix that + // c.do() applies for the gateway route must not be used here. + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", 0, fmt.Errorf("consent client: token service request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 0, fmt.Errorf("consent client: failed to read token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("consent client: token service returned status %d, body: %s", + resp.StatusCode, truncateBody(body)) + } + var out tokenResponse if err := json.Unmarshal(body, &out); err != nil { - return "", fmt.Errorf("consent client: failed to unmarshal login response: %w", err) + return "", 0, fmt.Errorf("consent client: failed to unmarshal token response: %w", err) } - if out.JWT == "" { - return "", fmt.Errorf("consent client: participant login returned no token") + if out.AccessToken == "" { + return "", 0, fmt.Errorf("consent client: token service returned no access token") } - return out.JWT, nil + return out.AccessToken, time.Duration(out.ExpiresIn) * time.Second, nil } // meResponse is the (subset of the) consent-manager response to GET /participants/me. @@ -397,17 +576,25 @@ func (c *Client) resolveUserIdentifier(ctx context.Context, subject, providerSD, return out.UserIdentifier, out.UserIdentifier != "", nil } -// participantConsentsResponse is the consent-manager response to call 2. +// participantConsentsResponse is the consent-manager response to call 2. The +// ?receipt=true form returns the raw consents, each carrying its status and the +// data resources it covers. type participantConsentsResponse struct { Consents []struct { Status string `json:"status"` + Data []struct { + Resource string `json:"resource"` + } `json:"data"` } `json:"consents"` } // hasGrantedConsent performs call 2: it lists the user identifier's consents as -// seen by the participant and reports whether any is granted. A 401 is returned -// as errParticipantUnauthorized so the caller can refresh the token and retry. -func (c *Client) hasGrantedConsent(ctx context.Context, token, userIdentifier string) (bool, error) { +// seen by the participant and reports whether any granted consent authorizes +// access. When dataResource is empty the check is owner-level (any granted +// consent suffices); otherwise a granted consent counts only if it covers that +// resource (dataResource ∈ consent.data[].resource). A 401 is returned as +// errParticipantUnauthorized so the caller can refresh the token and retry. +func (c *Client) hasGrantedConsent(ctx context.Context, token, userIdentifier, dataResource string) (bool, error) { endpoint := c.endpoint(fmt.Sprintf(participantConsentsPathFmt, url.PathEscape(userIdentifier))) + "?receipt=true" httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -431,9 +618,17 @@ func (c *Client) hasGrantedConsent(ctx context.Context, token, userIdentifier st return false, fmt.Errorf("consent client: failed to unmarshal consents response: %w", err) } for _, consent := range out.Consents { - if consent.Status == grantedStatus { + if consent.Status != grantedStatus { + continue + } + if dataResource == "" { return true, nil } + for _, d := range consent.Data { + if d.Resource == dataResource { + return true, nil + } + } } return false, nil } @@ -448,6 +643,9 @@ func (c *Client) endpoint(path string) string { // not surface the *http.Response: the body is already consumed and closed, so // callers only need the status code and body bytes. func (c *Client) do(httpReq *http.Request) (statusCode int, body []byte, err error) { + if c.host != "" { + httpReq.Host = c.host + } resp, err := c.httpClient.Do(httpReq) if err != nil { return 0, nil, fmt.Errorf("consent client: HTTP request failed: %w", err) diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 1309f77..7ebbe2a 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package consent import ( @@ -14,21 +31,31 @@ import ( "github.com/stretchr/testify/require" ) -// mockCM is a configurable mock consent-manager covering the four endpoints the -// client uses: /participants/login, /participants/me, /users/identifier/search -// and /consents/participants/{id}. +// tokenServicePath is where the mock serves the participant-local token service. +const tokenServicePath = "/internal/tokens" + +// testAudience is the configured token-service target the tests ask for. +const testAudience = "consent-manager" + +// mockCM is a configurable mock covering the four endpoints the client uses: the +// participant-local token service (/internal/tokens, served here for convenience +// on the same test server), plus the consent-manager's /participants/me, +// /users/identifier/search and /consents/participants/{id}. type mockCM struct { mu sync.Mutex // behaviour - userID string // identifier-search result ("" => 404) - statuses []string // consents statuses - selfDescriptionURL string // /me result - loginStatus int // non-200 => login fails with this status - failFirstConsents bool // first consents call 401s, then succeeds + userID string // identifier-search result ("" => 404) + statuses []string // consents statuses + resourcesPerConsent [][]string // optional data[].resource per consent (index-aligned with statuses) + selfDescriptionURL string // /me result + tokenStatus int // non-200 => the token service fails with this status + failFirstConsents bool // first consents call 401s, then succeeds // recording - loginCalls, meCalls, searchCalls, consentsCalls int + tokenCalls, meCalls, searchCalls, consentsCalls int lastConsentKey, lastSearchEmail, lastSearchSD string - lastConsentsAuth, lastLoginAuthClientID string + lastHost string + lastConsentsAuth, lastTokenAudience string + lastTokenHost string tokenCounter int } @@ -36,13 +63,14 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/v1/participants/login", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(tokenServicePath, func(w http.ResponseWriter, r *http.Request) { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) m.mu.Lock() - m.loginCalls++ - m.lastLoginAuthClientID = body["clientID"] - st := m.loginStatus + m.tokenCalls++ + m.lastTokenAudience = body["audience"] + m.lastTokenHost = r.Host + st := m.tokenStatus m.tokenCounter++ tok := fmt.Sprintf("token-%d", m.tokenCounter) m.mu.Unlock() @@ -51,12 +79,15 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "jwt": tok}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": tok, "token_type": "Bearer", "expires_in": 3600, + }) }) mux.HandleFunc("/v1/participants/me", func(w http.ResponseWriter, r *http.Request) { m.mu.Lock() m.meCalls++ + m.lastHost = r.Host sd := m.selfDescriptionURL m.mu.Unlock() w.Header().Set("Content-Type", "application/json") @@ -88,14 +119,23 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { m.lastConsentsAuth = r.Header.Get("Authorization") fail := m.failFirstConsents sts := append([]string(nil), m.statuses...) + res := append([][]string(nil), m.resourcesPerConsent...) m.mu.Unlock() if fail && n == 1 { w.WriteHeader(http.StatusUnauthorized) return } - consents := make([]map[string]string, 0, len(sts)) - for _, s := range sts { - consents = append(consents, map[string]string{"status": s}) + consents := make([]map[string]interface{}, 0, len(sts)) + for i, s := range sts { + consent := map[string]interface{}{"status": s} + if i < len(res) { + data := make([]map[string]string, 0, len(res[i])) + for _, r := range res[i] { + data = append(data, map[string]string{"resource": r}) + } + consent["data"] = data + } + consents = append(consents, consent) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) @@ -113,9 +153,62 @@ func resetCredCache() { credCacheMu.Unlock() } +// TestCheckConsent_HostOverride verifies the configured Host header is sent to +// the consent-manager (for host-scoped gateway routes) while the connection +// still targets the URL host. +func TestCheckConsent_HostOverride(t *testing.T) { + resetCredCache() + m := &mockCM{userID: "uid-1", selfDescriptionURL: "http://provider/sd", statuses: []string{"granted"}} + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, Host: "consent-manager.dataspace-authority.org", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience, ConsentKey: "ck"}) + if _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42"}); err != nil { + t.Fatalf("CheckConsent: %v", err) + } + if m.lastHost != "consent-manager.dataspace-authority.org" { + t.Fatalf("Host header not overridden on the consent-manager call, got %q", m.lastHost) + } + // The override exists for the host-scoped gateway route in front of the + // consent-manager. The token service is a local, same-namespace service, so it + // must be addressed by its own host - overriding it there would misroute the + // request. + if m.lastTokenHost == "consent-manager.dataspace-authority.org" { + t.Fatalf("Host override must not be applied to the token service, got %q", m.lastTokenHost) + } +} + +// TestCheckConsent_ResourceScoped verifies the (owner × dataResource) check: a +// granted consent authorizes only the resources it covers, while an empty +// DataResource is owner-level (any granted consent counts). +func TestCheckConsent_ResourceScoped(t *testing.T) { + resetCredCache() + m := &mockCM{ + userID: "uid-1", + selfDescriptionURL: "http://provider/sd", + statuses: []string{"granted"}, + resourcesPerConsent: [][]string{{"urn:ngsi-ld:PersonalProfile:alice"}}, + } + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) + + // resource covered by a granted consent -> allow + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", DataResource: "urn:ngsi-ld:PersonalProfile:alice"}) + require.NoError(t, err) + assert.Equal(t, DecisionAllow, resp.Decision) + + // a different resource -> deny (the consent does not cover it) + resp, err = c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", DataResource: "urn:ngsi-ld:PersonalProfile:bob"}) + require.NoError(t, err) + assert.Equal(t, DecisionDeny, resp.Decision) + + // owner-level (no resource) -> allow on any granted consent + resp, err = c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42"}) + require.NoError(t, err) + assert.Equal(t, DecisionAllow, resp.Decision) +} + // TestNewClient verifies the constructor applies defaults and stores parameters. func TestNewClient(t *testing.T) { - c := NewClient(ClientConfig{BaseURL: "http://cm:3000", ConsentKey: "ck", ClientID: "cid", ClientSecret: "sec"}) + c := NewClient(ClientConfig{BaseURL: "http://cm:3000", ConsentKey: "ck", TokenServiceURL: "http://consent-facade:8080" + tokenServicePath, TokenAudience: testAudience}) assert.Equal(t, "http://cm:3000", c.baseURL) assert.Equal(t, DefaultAPIPrefix, c.apiPrefix, "empty prefix defaults to /v1") assert.Equal(t, DefaultTokenTTL, c.tokenTTL, "zero ttl defaults") @@ -160,7 +253,7 @@ func TestCheckConsent(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 0, m.loginCalls, "static token must not trigger login") + assert.Equal(t, 0, m.tokenCalls, "static token must not call the token service") assert.Equal(t, 0, m.meCalls, "static SD must not trigger /me") assert.Equal(t, "ck", m.lastConsentKey) assert.Equal(t, provSD, m.lastSearchSD) @@ -174,11 +267,11 @@ func TestCheckConsent(t *testing.T) { // TestCheckConsent_ClientCredentials verifies the full client-credentials flow: // login for a token, derive the provider SD from /me, then run the two calls. -func TestCheckConsent_ClientCredentials(t *testing.T) { +func TestCheckConsent_TokenService(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, selfDescriptionURL: "http://facade/participants/derived"} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "consent-demo-provider", ClientSecret: "demo"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) require.NoError(t, err) @@ -186,8 +279,8 @@ func TestCheckConsent_ClientCredentials(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 1, m.loginCalls) - assert.Equal(t, "consent-demo-provider", m.lastLoginAuthClientID) + assert.Equal(t, 1, m.tokenCalls) + assert.Equal(t, testAudience, m.lastTokenAudience) assert.Equal(t, 1, m.meCalls) assert.Equal(t, "http://facade/participants/derived", m.lastSearchSD, "SD derived from /me is used in the search") assert.Equal(t, "Bearer token-1", m.lastConsentsAuth, "the fetched token is used on the consents call") @@ -199,7 +292,7 @@ func TestCheckConsent_TokenAndSDCached(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, selfDescriptionURL: "http://facade/participants/derived"} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "cid", ClientSecret: "sec"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) for i := 0; i < 3; i++ { resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) @@ -209,7 +302,7 @@ func TestCheckConsent_TokenAndSDCached(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 1, m.loginCalls, "token should be cached across calls") + assert.Equal(t, 1, m.tokenCalls, "token should be cached across calls") assert.Equal(t, 1, m.meCalls, "provider SD should be cached across calls") assert.Equal(t, 3, m.consentsCalls) } @@ -220,7 +313,7 @@ func TestCheckConsent_401RefreshRetry(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, selfDescriptionURL: "http://facade/sd", failFirstConsents: true} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "cid", ClientSecret: "sec"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) require.NoError(t, err) @@ -228,21 +321,21 @@ func TestCheckConsent_401RefreshRetry(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 2, m.loginCalls, "401 should force a re-login") + assert.Equal(t, 2, m.tokenCalls, "401 should force a token refresh") assert.Equal(t, 2, m.consentsCalls, "the consents call should be retried once") assert.Equal(t, "Bearer token-2", m.lastConsentsAuth, "the retry uses the refreshed token") } // TestCheckConsent_LoginFailure surfaces a login error. -func TestCheckConsent_LoginFailure(t *testing.T) { +func TestCheckConsent_TokenServiceFailure(t *testing.T) { resetCredCache() - m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, loginStatus: http.StatusNotFound} + m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, tokenStatus: http.StatusNotFound} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "cid", ClientSecret: "wrong"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) require.Error(t, err) - assert.Contains(t, err.Error(), "participant login returned status 404") + assert.Contains(t, err.Error(), "token service returned status 404") } // TestCheckConsent_ProviderSDOverride verifies an explicit provider_sd skips /me @@ -251,7 +344,7 @@ func TestCheckConsent_ProviderSDOverride(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "cid", ClientSecret: "sec", ProviderSD: "http://facade/explicit"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience, ProviderSD: "http://facade/explicit"}) resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) require.NoError(t, err) @@ -259,7 +352,7 @@ func TestCheckConsent_ProviderSDOverride(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 1, m.loginCalls, "token still comes from login") + assert.Equal(t, 1, m.tokenCalls, "token still comes from the token service") assert.Equal(t, 0, m.meCalls, "explicit provider_sd skips /me") assert.Equal(t, "http://facade/explicit", m.lastSearchSD) } @@ -269,25 +362,25 @@ func TestCheckConsent_EmptySubject(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid", statuses: []string{"granted"}} srv := newMockCM(t, m) - c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ClientID: "cid", ClientSecret: "sec"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: ""}) require.NoError(t, err) assert.Equal(t, DecisionDeny, resp.Decision) m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 0, m.loginCalls) + assert.Equal(t, 0, m.tokenCalls) assert.Equal(t, 0, m.searchCalls) } -// TestCheckConsent_MissingCredentials errors when neither a static token nor -// client credentials are configured. -func TestCheckConsent_MissingCredentials(t *testing.T) { +// TestCheckConsent_MissingTokenSource errors when neither a static token nor a +// token service is configured. +func TestCheckConsent_MissingTokenSource(t *testing.T) { resetCredCache() c := NewClient(ClientConfig{BaseURL: "http://cm:3000", ConsentKey: "ck"}) _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) require.Error(t, err) - assert.Contains(t, err.Error(), "no participant_token and no client_id/client_secret") + assert.Contains(t, err.Error(), "no participant_token and no token_service_url") } // TestCheckConsent_EmptyConsentKeyOmitsHeader verifies that an empty consent key @@ -307,7 +400,7 @@ func TestCheckConsent_EmptyConsentKeyOmitsHeader(t *testing.T) { // TestCheckConsent_ConcurrentLoginCoalesced verifies that concurrent first // requests for the same participant coalesce onto a single client-credentials // login (no stampede) and a single /me fetch, rather than one per goroutine. -func TestCheckConsent_ConcurrentLoginCoalesced(t *testing.T) { +func TestCheckConsent_ConcurrentTokenFetchCoalesced(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, selfDescriptionURL: "sd"} srv := newMockCM(t, m) @@ -320,7 +413,7 @@ func TestCheckConsent_ConcurrentLoginCoalesced(t *testing.T) { go func() { defer wg.Done() // Same base URL + client id => same cache key, so the login must coalesce. - c := NewClient(ClientConfig{BaseURL: srv.URL, ClientID: "cid", ClientSecret: "sec"}) + c := NewClient(ClientConfig{BaseURL: srv.URL, TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) if err != nil { errs <- err @@ -337,7 +430,7 @@ func TestCheckConsent_ConcurrentLoginCoalesced(t *testing.T) { m.mu.Lock() defer m.mu.Unlock() - assert.Equal(t, 1, m.loginCalls, "concurrent first requests must coalesce onto one login") + assert.Equal(t, 1, m.tokenCalls, "concurrent first requests must coalesce onto one token fetch") assert.Equal(t, 1, m.meCalls, "provider SD should be fetched once and cached") } diff --git a/internal/consent/models.go b/internal/consent/models.go index a23b234..9d7dc28 100644 --- a/internal/consent/models.go +++ b/internal/consent/models.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package consent provides an HTTP client for communicating with an external // consent API that determines whether response data should be allowed, denied, // or filtered based on consent policies for personal data. @@ -48,6 +65,11 @@ type ConsentRequest struct { // Method is the HTTP method of the original request (e.g., "GET", "POST"). Method string `json:"method"` + // DataResource, when set, scopes the check: a granted consent counts only if + // it covers this resource (matched against the consent's data[].resource). + // Empty means owner-level (any granted consent counts). + DataResource string `json:"data_resource,omitempty"` + // Claims contains the forwarded JWT claims as key-value pairs. Claims map[string]interface{} `json:"claims,omitempty"` diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index d0933ed..138d3d3 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package integration provides end-to-end integration tests for the // consent-filter plugin. Each test starts a mock consent API server, // creates a real plugin instance with configuration pointing to it, @@ -406,17 +423,21 @@ func TestIntegration_ContextCleanupAfterCycle(t *testing.T) { // newConsentManagerCC starts a mock consent-manager exposing all four endpoints // used by the client-credentials flow (login, me, identifier search, consents). -func newConsentManagerCC(t *testing.T, wantSubject, userID, selfDescriptionURL string, statuses []string) *httptest.Server { +func newConsentManagerTokenService(t *testing.T, wantSubject, userID, selfDescriptionURL string, statuses []string) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/v1/participants/login", func(w http.ResponseWriter, r *http.Request) { + // The participant-local token service (the consent-facade's /internal/tokens), + // served on the same test server for convenience. The plugin holds no + // participant credentials - it asks for a token by audience NAME. + mux.HandleFunc("/internal/tokens", func(w http.ResponseWriter, r *http.Request) { var b map[string]string _ = json.NewDecoder(r.Body).Decode(&b) - assert.Equal(t, "consent-demo-provider", b["clientID"]) - assert.Equal(t, "demo", b["clientSecret"]) + assert.Equal(t, "consent-manager", b["audience"]) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "jwt": "itest-token"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "itest-token", "token_type": "Bearer", "expires_in": 3600, + }) }) mux.HandleFunc("/v1/participants/me", func(w http.ResponseWriter, r *http.Request) { @@ -456,37 +477,37 @@ func newConsentManagerCC(t *testing.T, wantSubject, userID, selfDescriptionURL s // ccConfig is a plugin config using participant client credentials (no static // token, no explicit provider_sd — both are obtained from the consent-manager). -func ccConfig(consentURL string) map[string]interface{} { +func tokenServiceConfig(consentURL string) map[string]interface{} { return map[string]interface{}{ "consent_api_url": consentURL, "consent_key": "itest-consent-key", - "client_id": "consent-demo-provider", - "client_secret": "demo", + "token_service_url": consentURL + "/internal/tokens", "jwt_claims_to_forward": []string{"sub"}, } } -// TestIntegration_ClientCredentialsFlow drives the full client-credentials path: -// login for a token, derive the provider SD from /me, then the two-call check. -func TestIntegration_ClientCredentialsFlow(t *testing.T) { - srv := newConsentManagerCC(t, "did:key:zAlice", "uid-1", +// TestIntegration_TokenServiceFlow drives the full OID4VP-token path: fetch a +// token from the participant-local token service, derive the provider SD from +// /me, then the two-call check. +func TestIntegration_TokenServiceFlow(t *testing.T) { + srv := newConsentManagerTokenService(t, "did:key:zAlice", "uid-1", "http://consent-facade:8080/participants/derived", []string{"granted"}) defer srv.Close() - resp := runPluginCycle(t, marshalConfig(t, ccConfig(srv.URL)), + resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL)), consentRequest(20, "did:key:zAlice"), []byte(`{"ok":true}`)) assert.Nil(t, resp.writtenBody, "granted consent via client credentials should pass through") } -// TestIntegration_ClientCredentialsDenied verifies a not-granted consent denies -// via the client-credentials path. -func TestIntegration_ClientCredentialsDenied(t *testing.T) { - srv := newConsentManagerCC(t, "", "uid-1", +// TestIntegration_TokenServiceDenied verifies a not-granted consent denies via +// the OID4VP-token path. +func TestIntegration_TokenServiceDenied(t *testing.T) { + srv := newConsentManagerTokenService(t, "", "uid-1", "http://consent-facade:8080/participants/derived", []string{"revoked"}) defer srv.Close() - resp := runPluginCycle(t, marshalConfig(t, ccConfig(srv.URL)), + resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL)), consentRequest(21, "did:key:zAlice"), []byte(`{"secret":"x"}`)) assert.Equal(t, 403, resp.writtenStatus) diff --git a/internal/jwt/extractor.go b/internal/jwt/extractor.go index 4d38d4c..88e60df 100644 --- a/internal/jwt/extractor.go +++ b/internal/jwt/extractor.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package jwt provides functions for extracting and decoding JWT tokens // from HTTP request headers without performing signature verification. // Signature verification is expected to be handled by APISIX or the upstream service. diff --git a/internal/jwt/extractor_test.go b/internal/jwt/extractor_test.go index 93c4833..edd2218 100644 --- a/internal/jwt/extractor_test.go +++ b/internal/jwt/extractor_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/internal/ownerresolver/client.go b/internal/ownerresolver/client.go new file mode 100644 index 0000000..5298092 --- /dev/null +++ b/internal/ownerresolver/client.go @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package ownerresolver is a client for the external OwnerResolver service. +// +// The OwnerResolver answers, from the DATA alone (never the requestor), whether +// a payload needs a consent check and who its data owner(s) are. The plugin +// calls it in the response phase and then verifies consent per resolved owner. +package ownerresolver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Body encodings understood by the resolver. +const ( + encodingJSON = "json" + encodingNone = "none" + + // DefaultTimeoutMs is the default per-call timeout for /resolve. + DefaultTimeoutMs = 2000 + + resolvePath = "/resolve" + contentTypeJSON = "application/json" +) + +// Selector locates a claim within the payload (mirrors the resolver contract). +type Selector struct { + Type string `json:"type"` + Value string `json:"value,omitempty"` +} + +// Claim is one (owner [× dataResource]) requirement found in the data. +type Claim struct { + Selector Selector `json:"selector"` + OwnerID string `json:"ownerId"` + Participant string `json:"participant,omitempty"` + DataResource string `json:"dataResource,omitempty"` +} + +// Result is the OwnerResolver response. +type Result struct { + ConsentRequired bool `json:"consentRequired"` + Scheme string `json:"scheme,omitempty"` + Claims []Claim `json:"claims"` +} + +type resourceDescriptor struct { + Service string `json:"service,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + ContentType string `json:"contentType,omitempty"` +} + +type bodyDescriptor struct { + Encoding string `json:"encoding"` + Content json.RawMessage `json:"content,omitempty"` +} + +// Parties names the exchange participants. It exists ONLY so the resolver can +// identify the governing contract; it must never be used to determine the data +// owner (that comes from the data itself). +type Parties struct { + // Consumer is the requesting participant (e.g. the credential issuer DID). + Consumer string `json:"consumer,omitempty"` + // Provider is this participant's self-description URL - one side of the + // contract lookup. + Provider string `json:"provider,omitempty"` +} + +// IsZero reports whether no party is known, in which case the field is omitted. +func (p Parties) IsZero() bool { return p.Consumer == "" && p.Provider == "" } + +type resolveRequest struct { + Resource resourceDescriptor `json:"resource"` + Parties *Parties `json:"parties,omitempty"` + Body *bodyDescriptor `json:"body,omitempty"` +} + +// Client calls the OwnerResolver /resolve endpoint. +type Client struct { + url string + httpClient *http.Client +} + +// NewClient builds a resolver client for the given /resolve URL. +func NewClient(url string, timeoutMs int) *Client { + if timeoutMs <= 0 { + timeoutMs = DefaultTimeoutMs + } + return &Client{ + url: url, + httpClient: &http.Client{Timeout: time.Duration(timeoutMs) * time.Millisecond}, + } +} + +// Resource identifies the data being resolved (never the requestor). +type Resource struct { + Service string + Method string + Path string + ContentType string +} + +// Resolve asks the OwnerResolver about a payload. payload may be nil, in which +// case the body is sent with encoding "none" (the resolver decides from the +// resource descriptor alone). consumer, when non-empty, is forwarded so the +// resolver can find the governing contract - they are never used for ownership. +// A non-2xx response is returned as an error so the caller can apply its fail +// policy — it never means "no consent needed". +func (c *Client) Resolve(ctx context.Context, res Resource, p Parties, payload []byte) (Result, error) { + reqBody := &bodyDescriptor{Encoding: encodingNone} + if len(payload) > 0 && json.Valid(payload) { + reqBody = &bodyDescriptor{Encoding: encodingJSON, Content: json.RawMessage(payload)} + } + req := resolveRequest{ + Resource: resourceDescriptor(res), + Body: reqBody, + } + if !p.IsZero() { + req.Parties = &p + } + raw, err := json.Marshal(req) + if err != nil { + return Result{}, fmt.Errorf("owner-resolver: marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(raw)) + if err != nil { + return Result{}, fmt.Errorf("owner-resolver: create request: %w", err) + } + httpReq.Header.Set("Content-Type", contentTypeJSON) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return Result{}, fmt.Errorf("owner-resolver: request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return Result{}, fmt.Errorf("owner-resolver: read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Result{}, fmt.Errorf("owner-resolver: status %d: %s", resp.StatusCode, truncate(body)) + } + + var out Result + if err := json.Unmarshal(body, &out); err != nil { + return Result{}, fmt.Errorf("owner-resolver: decode response: %w", err) + } + return out, nil +} + +func truncate(b []byte) string { + const limit = 256 + if len(b) <= limit { + return string(b) + } + return string(b[:limit]) + "...(truncated)" +} diff --git a/internal/ownerresolver/client_test.go b/internal/ownerresolver/client_test.go new file mode 100644 index 0000000..0b12f0a --- /dev/null +++ b/internal/ownerresolver/client_test.go @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ownerresolver + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// testConsumer is the consuming participant forwarded for CONTRACT lookup only. +const testConsumer = "did:web:fancy-marketplace.biz" + +func TestResolve_SendsBodyAndParsesClaims(t *testing.T) { + var gotPath string + var gotReq resolveRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotReq) + // the resolver must not receive any caller identity + if strings.Contains(string(raw), "authorization") || strings.Contains(string(raw), "Authorization") { + t.Errorf("resolve request leaked a caller identity: %s", raw) + } + _, _ = w.Write([]byte(`{"consentRequired":true,"scheme":"identifier","claims":[{"selector":{"type":"json-pointer","value":""},"ownerId":"alice-42","dataResource":"urn:ngsi-ld:PersonalProfile:alice"}]}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL+resolvePath, 0) + res, err := c.Resolve(context.Background(), Resource{ + Service: "mp-data-service", Method: "GET", + Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:alice", ContentType: "application/ld+json", + }, Parties{Consumer: testConsumer}, []byte(`{"id":"urn:ngsi-ld:PersonalProfile:alice","dataOwner":"alice-42"}`)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if gotPath != resolvePath { + t.Fatalf("posted to %q", gotPath) + } + if gotReq.Body == nil || gotReq.Body.Encoding != encodingJSON { + t.Fatalf("expected json body, got %+v", gotReq.Body) + } + if gotReq.Resource.Service != "mp-data-service" || gotReq.Resource.Path == "" { + t.Fatalf("resource not forwarded: %+v", gotReq.Resource) + } + if gotReq.Parties == nil || gotReq.Parties.Consumer != testConsumer { + t.Fatalf("consumer not forwarded for contract lookup: %+v", gotReq.Parties) + } + if !res.ConsentRequired || len(res.Claims) != 1 || + res.Claims[0].OwnerID != "alice-42" || + res.Claims[0].DataResource != "urn:ngsi-ld:PersonalProfile:alice" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestResolve_NoBodyUsesEncodingNone(t *testing.T) { + var gotReq resolveRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotReq) + _, _ = w.Write([]byte(`{"consentRequired":true,"claims":[{"selector":{"type":"whole"},"ownerId":"bob-7"}]}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL+resolvePath, 0) + res, err := c.Resolve(context.Background(), Resource{Service: "file-service", Path: "/files/bob-7/x.pdf"}, Parties{}, nil) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if gotReq.Body == nil || gotReq.Body.Encoding != encodingNone { + t.Fatalf("expected encoding none, got %+v", gotReq.Body) + } + if gotReq.Parties != nil { + t.Fatalf("no consumer given => parties must be omitted, got %+v", gotReq.Parties) + } + if len(res.Claims) != 1 || res.Claims[0].OwnerID != "bob-7" || res.Claims[0].DataResource != "" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestResolve_Non2xxIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":"cannot resolve owner"}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL+resolvePath, 0) + if _, err := c.Resolve(context.Background(), Resource{Path: "/x"}, Parties{}, nil); err == nil { + t.Fatal("expected error on non-2xx resolver response") + } +} diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 7e18dd9..04c52e6 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package plugin implements the APISIX consent-filter plugin that intercepts // HTTP responses and applies consent-based filtering for personal data. package plugin @@ -15,6 +32,21 @@ const ( // DefaultConsentAPITimeout is the default timeout in milliseconds for consent API calls. DefaultConsentAPITimeout = 5000 + // DefaultOwnerResolverTimeout is the default timeout in milliseconds for + // OwnerResolver calls. + DefaultOwnerResolverTimeout = 2000 + + // DefaultConsumerClaim is the dotted claim path holding the consuming + // participant's identity. The provider's verifier embeds the presented + // credential in the access token (jwtInclusion.fullInclusion), so the + // credential's issuer is the consumer. + DefaultConsumerClaim = "verifiableCredential.issuer" + + // DefaultTokenAudience is the audience asked of the token service when none is + // configured. It names the token service's configured target for the + // consent-manager, not a URL. + DefaultTokenAudience = "consent-manager" + // DefaultJWTHeaderName is the default HTTP header containing the JWT token. DefaultJWTHeaderName = "Authorization" @@ -53,17 +85,24 @@ const ( // EnvConsentKey supplies ConsentKey (x-visionstrust-consent-key). EnvConsentKey = "CONSENT_KEY" - // EnvClientID supplies ClientID (participant client-credentials login). - EnvClientID = "CONSENT_CLIENT_ID" - - // EnvClientSecret supplies ClientSecret (participant client-credentials login). - EnvClientSecret = "CONSENT_CLIENT_SECRET" + // EnvTokenServiceURL supplies TokenServiceURL (the participant-local OID4VP + // token service). + EnvTokenServiceURL = "CONSENT_TOKEN_SERVICE_URL" // EnvAuditOTLPEndpoint supplies AuditOTLPEndpoint (the OTLP/HTTP Collector // endpoint access-decision audit events are exported to). EnvAuditOTLPEndpoint = "CONSENT_AUDIT_OTLP_ENDPOINT" ) +// Accepted URL schemes for the configured endpoints. +const ( + // schemeHTTP is the plain-HTTP URL scheme. + schemeHTTP = "http" + + // schemeHTTPS is the TLS-protected URL scheme. + schemeHTTPS = "https" +) + // Config holds the plugin configuration that APISIX passes as JSON. // It defines how the consent-filter plugin connects to the external consent API // and how it handles denial responses. @@ -88,29 +127,60 @@ type Config struct { // paths. Defaults to DefaultConsentAPIPrefix ("/v1"). ConsentAPIPrefix string `json:"consent_api_prefix,omitempty"` + // OwnerResolverURL is the external OwnerResolver /resolve endpoint. When set, + // the data owner is resolved from the RESPONSE DATA (never the requestor): + // the plugin posts the payload, gets back (owner[, dataResource]) claims, and + // checks consent per owner. When empty, the plugin falls back to the legacy + // behaviour of taking the subject from the JWT. + OwnerResolverURL string `json:"owner_resolver_url,omitempty"` + + // ConsentAPIHost overrides the HTTP Host header sent on consent-manager + // calls. Needed when ConsentAPIURL points at an in-cluster gateway service + // whose routes are host-scoped to the public ingress name: the TCP connection + // still uses the URL's host, but the Host header must equal the route's host + // or APISIX returns "404 Route Not Found". + ConsentAPIHost string `json:"consent_api_host,omitempty"` + + // OwnerResolverTimeout is the per-call timeout in milliseconds for the + // OwnerResolver (defaults to DefaultOwnerResolverTimeout). + OwnerResolverTimeout int `json:"owner_resolver_timeout,omitempty"` + + // Service is the logical dataset id sent to the OwnerResolver as + // resource.service, so it can select the right rule for this route. + Service string `json:"service,omitempty"` + + // ConsumerClaim is the dotted path of the token claim identifying the + // consuming participant, forwarded to the OwnerResolver as parties.consumer so + // it can find the governing contract. Defaults to DefaultConsumerClaim. It is + // used for contract lookup ONLY - never to determine the data owner. + ConsumerClaim string `json:"consumer_claim,omitempty"` + // ConsentKey is the shared secret sent as the x-visionstrust-consent-key // header on the identifier-search call. Optional: when the plugin runs // behind the authority's facade, the facade injects the key server-side and // this is not needed. Falls back to the EnvConsentKey env var when empty. ConsentKey string `json:"consent_key,omitempty"` - // ClientID / ClientSecret are the participant client credentials. When set, - // the plugin obtains (and refreshes) a participant token via - // /participants/login, and — when ProviderSD is empty — derives the provider - // self-description from /participants/me. Preferred over a static - // ParticipantToken, as these are stable while the token expires. Each falls - // back to its env var (EnvClientID / EnvClientSecret) when empty, so the - // secret need not sit in the route config. - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - - // ParticipantTokenTTL caps, in seconds, how long a client-credentials token - // is cached before re-login (defaults to 3000s). Ignored for a static token. + // TokenServiceURL is the participant-local OID4VP token service the plugin + // asks for an access token — the consent-facade's POST /internal/tokens. The + // plugin holds no participant credentials of its own: the facade presents the + // participant's verifiable credential and returns a short-lived token, which + // the plugin caches and refreshes. Falls back to EnvTokenServiceURL when + // empty. Required unless a static ParticipantToken is configured. + TokenServiceURL string `json:"token_service_url,omitempty"` + + // TokenAudience is the audience name asked of the token service (its + // configured target, not a URL). Defaults to DefaultTokenAudience. + TokenAudience string `json:"token_audience,omitempty"` + + // ParticipantTokenTTL caps, in seconds, how long a fetched token is cached + // (defaults to 3000s). The token service reports its own lifetime; the + // shorter of the two wins. Ignored for a static token. ParticipantTokenTTL int `json:"participant_token_ttl,omitempty"` - // ParticipantToken is an optional *static* participant JWT for the - // consents-lookup call. Legacy/override: prefer ClientID/ClientSecret so the - // token is fetched and refreshed automatically. + // ParticipantToken is an optional *static*, pre-obtained access token for the + // consents-lookup call. An override for tests and manual runs; normally the + // token comes from TokenServiceURL so it is refreshed automatically. ParticipantToken string `json:"participant_token,omitempty"` // ProviderSD is the provider self-description URL sent on the @@ -174,6 +244,15 @@ func (c *Config) applyDefaults() { if c.ConsentAPIPrefix == "" { c.ConsentAPIPrefix = DefaultConsentAPIPrefix } + if c.OwnerResolverURL != "" && c.OwnerResolverTimeout == 0 { + c.OwnerResolverTimeout = DefaultOwnerResolverTimeout + } + if c.OwnerResolverURL != "" && c.ConsumerClaim == "" { + c.ConsumerClaim = DefaultConsumerClaim + } + if c.TokenAudience == "" { + c.TokenAudience = DefaultTokenAudience + } if c.DenyStatusCode == 0 { c.DenyStatusCode = DefaultDenyStatusCode } @@ -194,11 +273,8 @@ func (c *Config) applyEnv() { if c.ConsentKey == "" { c.ConsentKey = os.Getenv(EnvConsentKey) } - if c.ClientID == "" { - c.ClientID = os.Getenv(EnvClientID) - } - if c.ClientSecret == "" { - c.ClientSecret = os.Getenv(EnvClientSecret) + if c.TokenServiceURL == "" { + c.TokenServiceURL = os.Getenv(EnvTokenServiceURL) } if c.AuditOTLPEndpoint == "" { c.AuditOTLPEndpoint = os.Getenv(EnvAuditOTLPEndpoint) @@ -216,7 +292,7 @@ func (c *Config) Validate() error { if err != nil { return fmt.Errorf("config validation: consent_api_url is not a valid URL: %w", err) } - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + if parsedURL.Scheme != schemeHTTP && parsedURL.Scheme != schemeHTTPS { return fmt.Errorf("config validation: consent_api_url must use http or https scheme, got %q", parsedURL.Scheme) } @@ -234,6 +310,26 @@ func (c *Config) Validate() error { return errors.New("config validation: audit_otlp_endpoint is required when audit_enabled is true") } + if c.OwnerResolverURL != "" { + resolverURL, err := url.ParseRequestURI(c.OwnerResolverURL) + if err != nil { + return fmt.Errorf("config validation: owner_resolver_url is not a valid URL: %w", err) + } + if resolverURL.Scheme != schemeHTTP && resolverURL.Scheme != schemeHTTPS { + return fmt.Errorf("config validation: owner_resolver_url must use http or https scheme, got %q", resolverURL.Scheme) + } + } + + if c.TokenServiceURL != "" { + tokenServiceURL, err := url.ParseRequestURI(c.TokenServiceURL) + if err != nil { + return fmt.Errorf("config validation: token_service_url is not a valid URL: %w", err) + } + if tokenServiceURL.Scheme != schemeHTTP && tokenServiceURL.Scheme != schemeHTTPS { + return fmt.Errorf("config validation: token_service_url must use http or https scheme, got %q", tokenServiceURL.Scheme) + } + } + return nil } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index bc6cac2..bb3abf1 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package plugin import ( @@ -217,39 +234,80 @@ func TestParseConfig(t *testing.T) { } } -// TestParseConfig_EnvFallback verifies the credential fields fall back to their -// env vars when omitted from the route config, and that a value in the config -// always takes precedence over the env var. +// TestParseConfig_EnvFallback verifies the credential-bearing fields fall back to +// their env vars when omitted from the route config, and that a value in the +// config always takes precedence over the env var. func TestParseConfig_EnvFallback(t *testing.T) { - t.Run("env fills empty credential fields", func(t *testing.T) { + t.Run("env fills empty fields", func(t *testing.T) { t.Setenv(EnvConsentKey, "ck-from-env") - t.Setenv(EnvClientID, "cid-from-env") - t.Setenv(EnvClientSecret, "sec-from-env") + t.Setenv(EnvTokenServiceURL, "http://facade-from-env:8080/internal/tokens") cfg, err := ParseConfig(toJSON(t, validConfigJSON())) require.NoError(t, err) assert.Equal(t, "ck-from-env", cfg.ConsentKey) - assert.Equal(t, "cid-from-env", cfg.ClientID) - assert.Equal(t, "sec-from-env", cfg.ClientSecret) + assert.Equal(t, "http://facade-from-env:8080/internal/tokens", cfg.TokenServiceURL) }) t.Run("config values win over env", func(t *testing.T) { t.Setenv(EnvConsentKey, "ck-from-env") - t.Setenv(EnvClientID, "cid-from-env") - t.Setenv(EnvClientSecret, "sec-from-env") + t.Setenv(EnvTokenServiceURL, "http://facade-from-env:8080/internal/tokens") in := validConfigJSON() in["consent_key"] = "ck-from-config" - in["client_id"] = "cid-from-config" - in["client_secret"] = "sec-from-config" + in["token_service_url"] = "http://facade-from-config:8080/internal/tokens" cfg, err := ParseConfig(toJSON(t, in)) require.NoError(t, err) assert.Equal(t, "ck-from-config", cfg.ConsentKey) - assert.Equal(t, "cid-from-config", cfg.ClientID) - assert.Equal(t, "sec-from-config", cfg.ClientSecret) + assert.Equal(t, "http://facade-from-config:8080/internal/tokens", cfg.TokenServiceURL) }) } +// TestParseConfig_TokenAudienceDefault verifies the audience defaults to the +// consent-manager target and that an explicit value is kept. +func TestParseConfig_TokenAudienceDefault(t *testing.T) { + cases := []struct { + name string + configured string + want string + }{ + {name: "defaults when omitted", configured: "", want: DefaultTokenAudience}, + {name: "keeps an explicit audience", configured: "some-other-target", want: "some-other-target"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := validConfigJSON() + if tc.configured != "" { + in["token_audience"] = tc.configured + } + cfg, err := ParseConfig(toJSON(t, in)) + require.NoError(t, err) + assert.Equal(t, tc.want, cfg.TokenAudience) + }) + } +} + +// TestParseConfig_TokenServiceURLValidation rejects a token service URL that is +// not an http(s) URL - a typo there would otherwise surface as a failed consent +// check on the data path. +func TestParseConfig_TokenServiceURLValidation(t *testing.T) { + cases := []struct { + name string + url string + }{ + {name: "not a URL", url: "not-a-url"}, + {name: "wrong scheme", url: "ftp://facade:8080/internal/tokens"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := validConfigJSON() + in["token_service_url"] = tc.url + _, err := ParseConfig(toJSON(t, in)) + require.Error(t, err) + assert.Contains(t, err.Error(), "token_service_url") + }) + } +} + // TestParseConfig_Audit covers the audit config: enabling requires an endpoint, // and the endpoint falls back to its env var. func TestParseConfig_Audit(t *testing.T) { diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 063cc43..735f79a 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package plugin implements the APISIX consent-filter plugin that intercepts // HTTP responses and applies consent-based filtering for personal data. package plugin @@ -6,9 +23,11 @@ import ( "consent-plugin/internal/audit" "consent-plugin/internal/consent" "consent-plugin/internal/jwt" + "consent-plugin/internal/ownerresolver" "context" "log" "net/http" + "strings" "time" pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http" @@ -104,7 +123,7 @@ func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r log.Printf("[consent-filter] RequestFilter: failed to extract JWT from header %q for request %d: %v", cfg.JWTHeaderName, r.ID(), err) } else { - claims, err := jwt.DecodeClaims(token, cfg.JWTClaimsToForward) + claims, err := jwt.DecodeClaims(token, claimKeysToDecode(cfg)) if err != nil { log.Printf("[consent-filter] RequestFilter: failed to decode JWT claims for request %d: %v", r.ID(), err) @@ -194,39 +213,154 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom return failOutcome(cfg, "no request context", key, nil) } - // Run the two-call consent check for the request subject. + consentClient := consent.NewClient(clientConfigFromCfg(cfg)) + + // Owner-resolver mode: resolve the data owner(s) from the response DATA and + // check consent per owner (never the requestor). Falls back to the legacy + // JWT-subject mode when no resolver is configured. + if cfg.OwnerResolverURL != "" { + return c.evaluateWithResolver(cfg, w, key, reqCtx, consentClient) + } + consentReq := buildConsentRequest(reqCtx) - consentClient := consent.NewClient(consent.ClientConfig{ - BaseURL: cfg.ConsentAPIURL, - APIPrefix: cfg.ConsentAPIPrefix, - ConsentKey: cfg.ConsentKey, - ProviderSD: cfg.ProviderSD, - ParticipantToken: cfg.ParticipantToken, - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - TokenTTL: time.Duration(cfg.ParticipantTokenTTL) * time.Second, - TimeoutMs: cfg.ConsentAPITimeout, - }) - consentResp, err := consentClient.CheckConsent(context.Background(), consentReq) + return checkConsent(cfg, key, consentClient, consentReq) +} + +// evaluateWithResolver reads the upstream body, asks the OwnerResolver who owns +// the data (and whether consent is required), and enforces deny_all: every +// distinct (owner, dataResource) claim must have a granted consent, or the whole +// response is denied. The requestor identity is never consulted. +func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, key string, reqCtx *RequestContext, consentClient *consent.Client) responseOutcome { + body, err := w.ReadBody() if err != nil { - log.Printf("[consent-filter] ResponseFilter: consent check error for request %d: %v", w.ID(), err) - return failOutcome(cfg, "consent check error: "+err.Error(), key, &consentReq) + log.Printf("[consent-filter] ResponseFilter: could not read upstream body for request %s: %v", key, err) + return failOutcome(cfg, "read upstream body: "+err.Error(), key, nil) + } + + contentType := "" + if h := w.Header(); h != nil { + contentType = h.Get("Content-Type") + } + + resolverClient := ownerresolver.NewClient(cfg.OwnerResolverURL, cfg.OwnerResolverTimeout) + // Parties are for CONTRACT identification only - never for ownership. The + // token names the consumer by DID, while contracts name their parties by + // self-description URL, so translate it via the participant registry. + resolveParties := ownerresolver.Parties{} + if consumerDID := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim); consumerDID != "" { + if consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(context.Background(), consumerDID); sdErr != nil { + log.Printf("[consent-filter] ResponseFilter: could not map consumer did %q to a participant for request %s: %v", consumerDID, key, sdErr) + } else { + resolveParties.Consumer = consumerSD + } + } + if providerSD, sdErr := consentClient.ProviderSelfDescription(context.Background()); sdErr != nil { + log.Printf("[consent-filter] ResponseFilter: could not determine the provider self-description for request %s: %v", key, sdErr) + } else { + resolveParties.Provider = providerSD + } + result, err := resolverClient.Resolve(context.Background(), ownerresolver.Resource{ + Service: cfg.Service, + Method: reqCtx.Method, + Path: reqCtx.Path, + ContentType: contentType, + }, resolveParties, body) + if err != nil { + log.Printf("[consent-filter] ResponseFilter: owner resolver error for request %s: %v", key, err) + return failOutcome(cfg, "owner resolver error: "+err.Error(), key, nil) + } + + if !result.ConsentRequired { + return responseOutcome{decision: decisionAllow, reason: "no consent required", requestID: key, resource: reqCtx.Path, method: reqCtx.Method} + } + if len(result.Claims) == 0 { + // Consent required but no owner could be resolved — fail closed. + return failOutcome(cfg, "consent required but no data owner resolved", key, nil) + } + + // deny_all: every distinct (owner, dataResource) claim must be granted. + type pair struct{ owner, resource string } + checked := make(map[pair]bool) + for _, claim := range result.Claims { + if claim.OwnerID == "" { + return failOutcome(cfg, "resolved claim without a data owner", key, nil) + } + p := pair{owner: claim.OwnerID, resource: claim.DataResource} + if checked[p] { + continue + } + checked[p] = true + + req := consent.ConsentRequest{ + Subject: claim.OwnerID, + Resource: reqCtx.Path, + Method: reqCtx.Method, + DataResource: claim.DataResource, + } + resp, err := consentClient.CheckConsent(context.Background(), req) + if err != nil { + log.Printf("[consent-filter] ResponseFilter: consent check error for request %s: %v", key, err) + return failOutcome(cfg, "consent check error: "+err.Error(), key, &req) + } + if resp.Decision != consent.DecisionAllow { + return responseOutcome{ + decision: decisionDeny, + reason: resp.Reason, + requestID: key, + subject: claim.OwnerID, + resource: resourceOrPath(claim.DataResource, reqCtx.Path), + method: reqCtx.Method, + } + } } + return responseOutcome{decision: decisionAllow, requestID: key, resource: reqCtx.Path, method: reqCtx.Method} +} +// checkConsent runs a single consent check and maps it to an outcome (legacy +// JWT-subject mode). +func checkConsent(cfg *Config, key string, client *consent.Client, req consent.ConsentRequest) responseOutcome { + resp, err := client.CheckConsent(context.Background(), req) + if err != nil { + return failOutcome(cfg, "consent check error: "+err.Error(), key, &req) + } decision := decisionDeny - if consentResp.Decision == consent.DecisionAllow { + if resp.Decision == consent.DecisionAllow { decision = decisionAllow } return responseOutcome{ decision: decision, - reason: consentResp.Reason, + reason: resp.Reason, requestID: key, - subject: consentReq.Subject, - resource: consentReq.Resource, - method: consentReq.Method, + subject: req.Subject, + resource: req.Resource, + method: req.Method, } } +// clientConfigFromCfg builds the consent-manager client config from the plugin config. +func clientConfigFromCfg(cfg *Config) consent.ClientConfig { + return consent.ClientConfig{ + BaseURL: cfg.ConsentAPIURL, + Host: cfg.ConsentAPIHost, + APIPrefix: cfg.ConsentAPIPrefix, + ConsentKey: cfg.ConsentKey, + ProviderSD: cfg.ProviderSD, + ParticipantToken: cfg.ParticipantToken, + TokenServiceURL: cfg.TokenServiceURL, + TokenAudience: cfg.TokenAudience, + TokenTTL: time.Duration(cfg.ParticipantTokenTTL) * time.Second, + TimeoutMs: cfg.ConsentAPITimeout, + } +} + +// resourceOrPath returns dataResource when set, else the request path (for audit). +func resourceOrPath(dataResource, path string) string { + if dataResource != "" { + return dataResource + } + return path +} + // failOutcome builds the outcome for an unresolved consent check, applying the // fail policy (allow when fail-open, otherwise deny). req may be nil when no // request context was captured. @@ -295,3 +429,52 @@ func denyResponse(w pkgHTTP.Response, cfg *Config) { log.Printf("[consent-filter] ResponseFilter: failed to write deny body for request %d: %v", w.ID(), err) } } + +// claimKeysToDecode returns the claim keys the request phase must decode: the +// configured forward list plus the root of the consumer-claim path, so the +// consumer can be read in the response phase. An empty result means "all claims". +func claimKeysToDecode(cfg *Config) []string { + if len(cfg.JWTClaimsToForward) == 0 { + // DecodeClaims returns every claim in this case - nothing to add. + return nil + } + keys := append([]string(nil), cfg.JWTClaimsToForward...) + if cfg.ConsumerClaim == "" { + return keys + } + root := strings.SplitN(cfg.ConsumerClaim, claimPathSeparator, 2)[0] + for _, k := range keys { + if k == root { + return keys + } + } + return append(keys, root) +} + +// claimPathSeparator separates the segments of a dotted claim path. +const claimPathSeparator = "." + +// consumerFromClaims reads the consuming participant from a dotted claim path +// (e.g. "verifiableCredential.issuer"). It returns "" when the path is unset or +// does not resolve to a string - the resolver then reports that it cannot +// identify the contract, and the fail policy applies. +func consumerFromClaims(claims map[string]interface{}, path string) string { + if len(claims) == 0 || path == "" { + return "" + } + var current interface{} = claims + for _, segment := range strings.Split(path, claimPathSeparator) { + node, ok := current.(map[string]interface{}) + if !ok { + return "" + } + current, ok = node[segment] + if !ok { + return "" + } + } + if s, ok := current.(string); ok { + return s + } + return "" +} diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 81280c4..6783f2d 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package plugin import ( diff --git a/internal/plugin/context.go b/internal/plugin/context.go index 0faf6cb..6efc9bb 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package plugin import ( diff --git a/internal/plugin/context_test.go b/internal/plugin/context_test.go index b9c51ac..1eba047 100644 --- a/internal/plugin/context_test.go +++ b/internal/plugin/context_test.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package plugin import ( diff --git a/main.go b/main.go index 628fd9a..87413ba 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Package main is the entry point for the APISIX go-plugin-runner. // It imports the consent-filter plugin package to trigger registration // via init() and starts the plugin runner.