From 7234f89dfdd1af07fccc08fe4469d1b4670fe4d8 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:32:10 +0200 Subject: [PATCH 01/41] fix(consent): key the credential cache on the full credential identity (C-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package-wide credential cache held both the participant access token and the provider self-description derived from it, but was keyed only on `baseURL + "|" + tokenAudience` — and TokenAudience defaults to the constant "consent-manager" for every route. Two routes fronting different provider tenants against the same consent-manager therefore collapsed onto one cache entry. Whichever route warmed it first installed its token and its selfDescriptionURL; the other then ran its identifier search scoped to the wrong provider and its consents lookup as the wrong participant. Decisions were silently wrong in both directions: denials for subjects who had consented, and allows against another provider's consent records. cacheKey() now covers every input that can change the cached token or SD: base URL, Host override, API prefix, token audience, token-service URL, provider SD, and the static token and consent key (hashed with SHA-256, so the key never retains a secret verbatim). Components are joined with a NUL separator, which cannot occur in a URL or audience name, so no two distinct identities can collide on the joined string. Tests: a table-driven case asserting a distinct key for each differing field, and an end-to-end regression test with two token services against one consent-manager asserting each participant presents its own token. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 48 +++++++++++++- internal/consent/client_test.go | 109 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index fab858e..30cfaaf 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -20,6 +20,8 @@ package consent import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -125,8 +127,9 @@ type ClientConfig struct { // 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". +// package-wide (keyed by the full credential identity, see cacheKey) and +// refreshed on expiry or a 401. Access is allowed iff a returned consent is +// "granted". type Client struct { baseURL string host string @@ -187,7 +190,46 @@ var ( credCache = map[string]*cacheEntry{} ) -func (c *Client) cacheKey() string { return c.baseURL + "|" + c.tokenAudience } +// credentialKeySeparator joins the components of a credential cache key. It is a +// character that cannot occur in a URL or an audience name, so no two distinct +// credential identities can produce the same joined key. +const credentialKeySeparator = "\x00" + +// cacheKey identifies the credential the cached entry belongs to. +// +// The entry holds both the participant access token and the provider +// self-description derived from it, so the key MUST cover every input that can +// change either of them - otherwise two routes fronting different participants +// but the same consent-manager share one entry, and whichever warms it first +// makes the other run its identifier search scoped to the wrong provider and its +// consents lookup as the wrong participant (silently wrong decisions in both +// directions). +// +// Secrets are hashed rather than embedded so the key can be logged or ranged +// over without leaking a token. +func (c *Client) cacheKey() string { + return strings.Join([]string{ + c.baseURL, + c.host, + c.apiPrefix, + c.tokenAudience, + c.tokenServiceURL, + c.providerSD, + hashSecret(c.staticToken), + hashSecret(c.consentKey), + }, credentialKeySeparator) +} + +// hashSecret returns a stable, non-reversible fingerprint of a secret, so it can +// distinguish cache identities without the secret itself being retained in the +// key. An empty secret maps to the empty string. +func hashSecret(secret string) string { + if secret == "" { + return "" + } + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} // CheckConsent runs the two-call consent verification for req.Subject, allowing // when a granted consent exists and denying otherwise. An unknown subject is a diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 7ebbe2a..f2aad2b 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -491,3 +491,112 @@ func TestTruncateBody(t *testing.T) { assert.Contains(t, long, "...(truncated)") assert.Equal(t, maxBodyLogLength+len("...(truncated)"), len(long)) } + +// TestCacheKeyDistinguishesCredentialIdentities verifies that two clients that +// differ in any input feeding the cached token or provider self-description get +// distinct cache keys. Sharing an entry across credential identities would make +// one route run its lookups as the wrong participant (see cacheKey). +func TestCacheKeyDistinguishesCredentialIdentities(t *testing.T) { + base := ClientConfig{ + BaseURL: "http://consent-manager:3000", + Host: "consent.example.org", + APIPrefix: "/v1", + ConsentKey: "ck-a", + ProviderSD: "http://facade/participants/a", + TokenServiceURL: "http://facade-a:8080/internal/tokens", + TokenAudience: testAudience, + } + + tests := []struct { + name string + mutate func(cfg *ClientConfig) + }{ + {"base url", func(cfg *ClientConfig) { cfg.BaseURL = "http://other-manager:3000" }}, + {"host", func(cfg *ClientConfig) { cfg.Host = "other.example.org" }}, + {"api prefix", func(cfg *ClientConfig) { cfg.APIPrefix = "/v2" }}, + {"consent key", func(cfg *ClientConfig) { cfg.ConsentKey = "ck-b" }}, + {"provider sd", func(cfg *ClientConfig) { cfg.ProviderSD = "http://facade/participants/b" }}, + {"token service url", func(cfg *ClientConfig) { cfg.TokenServiceURL = "http://facade-b:8080/internal/tokens" }}, + {"token audience", func(cfg *ClientConfig) { cfg.TokenAudience = "other-audience" }}, + {"static token", func(cfg *ClientConfig) { cfg.ParticipantToken = "static-b" }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + other := base + tc.mutate(&other) + assert.NotEqual(t, NewClient(base).cacheKey(), NewClient(other).cacheKey(), + "clients differing in %s must not share a credential cache entry", tc.name) + }) + } + + t.Run("identical config shares an entry", func(t *testing.T) { + assert.Equal(t, NewClient(base).cacheKey(), NewClient(base).cacheKey()) + }) + + t.Run("secrets are not embedded verbatim", func(t *testing.T) { + withSecrets := base + withSecrets.ParticipantToken = "super-secret-token" + key := NewClient(withSecrets).cacheKey() + assert.NotContains(t, key, "super-secret-token") + assert.NotContains(t, key, "ck-a") + }) +} + +// TestCheckConsent_SeparateTokenServicesDoNotShareToken is the regression test +// for the cross-participant cache collision: two routes fronting the SAME +// consent-manager but authenticating through their own token service must each +// present their own participant token. +func TestCheckConsent_SeparateTokenServicesDoNotShareToken(t *testing.T) { + resetCredCache() + + var mu sync.Mutex + var consentsAuth []string + + mux := http.NewServeMux() + mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-1"}) + }) + mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + consentsAuth = append(consentsAuth, r.Header.Get("Authorization")) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "consents": []map[string]string{{"status": grantedStatus}}, + }) + }) + consentManager := httptest.NewServer(mux) + t.Cleanup(consentManager.Close) + + // One token service per participant, each minting a distinguishable token. + newTokenService := func(token string) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": token, "token_type": "Bearer", "expires_in": 3600, + }) + })) + t.Cleanup(srv.Close) + return srv + } + + for _, token := range []string{"token-participant-a", "token-participant-b"} { + c := NewClient(ClientConfig{ + BaseURL: consentManager.URL, + ConsentKey: "ck", + ProviderSD: "http://facade/participants/" + token, + TokenServiceURL: newTokenService(token).URL, + TokenAudience: testAudience, + }) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + require.NoError(t, err) + require.Equal(t, DecisionAllow, resp.Decision) + } + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"Bearer token-participant-a", "Bearer token-participant-b"}, consentsAuth, + "each participant must authenticate with its own token, not the first one cached") +} From 18fccf4138d2bfc9b2591ffcb472a969acaf0316 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:38:36 +0200 Subject: [PATCH 02/41] fix(plugin)!: remove the legacy JWT-subject mode; require an OwnerResolver (C-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `owner_resolver_url` was unset — the documented default — the plugin took the consent subject from the access token's `sub` claim. That answers "has the CALLER granted some consent?", which establishes no link whatsoever between the caller and the data the upstream is about to return. Concretely: Alice holds one granted consent and a valid token. She requests /ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob. The plugin resolved *Alice's* user identifier, found *Alice's* granted consent, and returned *Bob's* personal data. Any subject with a single granted consent was a universal reader, so the gate was a no-op against the threat it exists for. The JWT signature is also not verified by this plugin, so on a route without an auth plugin the `sub` was attacker-supplied and the check collapsed entirely. The owner-resolver path already answers the right question — it derives ownership from the response DATA and checks consent per resolved owner — so the fix is to finish that migration rather than patch the unsound path: - `owner_resolver_url` is now required; `Validate()` rejects a config without one, so a route that cannot determine ownership fails to load instead of silently gating nothing. - `buildConsentRequest`, the legacy `checkConsent` helper and the `jwtSubjectClaim` constant are deleted; `evaluate()` always goes through the resolver. - `jwt_claims_to_forward` is documented for what it now does (naming the consumer for the contract lookup), not as the source of the data subject. - `RequestFilter` documents that the JWT is decoded, never verified, and that an authenticating plugin in front of this one is a hard route prerequisite. BREAKING CHANGE: routes without `owner_resolver_url` no longer load. Tests: the plugin and integration suites are ported to resolver mode, and TestResponseFilter_OwnerNotRequestor / TestIntegration_OwnerNotRequestor pin the property directly — the resolver names Bob as the owner while the token's `sub` is Alice, and the identifier search must ask about Bob. Co-Authored-By: Claude Opus 5 --- internal/integration/integration_test.go | 156 ++++-- internal/plugin/config.go | 45 +- internal/plugin/config_test.go | 21 +- internal/plugin/consent.go | 86 +-- internal/plugin/consent_test.go | 392 +++++++++----- review.md | 653 +++++++++++++++++++++++ 6 files changed, 1104 insertions(+), 249 deletions(-) create mode 100644 review.md diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 138d3d3..c887b48 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -204,18 +204,55 @@ func newConsentManager(t *testing.T, wantSubject, userID string, statuses []stri _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) + return httptest.NewServer(mux) } +// --- OwnerResolver mock (the source of data ownership) --- + +// itestConsumerDID is the consuming participant named in the access token. +const itestConsumerDID = "did:key:zConsumer" + +// participantRegistryHandler serves the consent-manager's participant registry, +// which translates the consumer DID from the token into the self-description URL +// a contract names its parties by. +func participantRegistryHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"did": itestConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, + }) +} + +// newOwnerResolver starts a mock OwnerResolver that reports the given data +// owners for every payload. With no owners, it reports that no consent is +// required. +func newOwnerResolver(t *testing.T, owners ...string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + claims := make([]map[string]string, 0, len(owners)) + for _, o := range owners { + claims = append(claims, map[string]string{"ownerId": o}) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "consentRequired": len(owners) > 0, + "claims": claims, + }); err != nil { + t.Errorf("failed to encode resolve response: %v", err) + } + })) +} + // baseConfig returns the minimal valid plugin configuration for the two-call -// check, pointing at the given consent-manager URL. -func baseConfig(consentURL string) map[string]interface{} { +// check, pointing at the given consent-manager and OwnerResolver. +func baseConfig(consentURL, resolverURL string) map[string]interface{} { return map[string]interface{}{ - "consent_api_url": consentURL, - "consent_key": "itest-consent-key", - "participant_token": "itest-participant-token", - "provider_sd": "http://consent-facade:8080/participants/org-itest", - "jwt_claims_to_forward": []string{"sub"}, + "consent_api_url": consentURL, + "owner_resolver_url": resolverURL, + "consent_key": "itest-consent-key", + "participant_token": "itest-participant-token", + "provider_sd": "http://consent-facade:8080/participants/org-itest", } } @@ -267,11 +304,15 @@ func runPluginCycle( return resp } -// consentRequest builds a GET request for a personal-data entity, carrying the -// given subject DID in the JWT "sub" claim (Authorization: Bearer ...). -func consentRequest(id uint32, subject string) *mockRequest { +// consentRequest builds a GET request for a personal-data entity. The token +// names the CONSUMER (as the embedded credential's issuer) and its "sub"; neither +// determines the data owner — the OwnerResolver does, from the response payload. +func consentRequest(id uint32, caller string) *mockRequest { h := newMockRequestHeader() - h.Set("Authorization", "Bearer "+buildMockJWT(map[string]interface{}{"sub": subject})) + h.Set("Authorization", "Bearer "+buildMockJWT(map[string]interface{}{ + "sub": caller, + "verifiableCredential": map[string]interface{}{"issuer": itestConsumerDID}, + })) return &mockRequest{ id: id, method: "GET", @@ -289,9 +330,11 @@ const defaultDenyBody = `{"error":"access denied by consent policy"}` func TestIntegration_GrantedConsentPassthrough(t *testing.T) { srv := newConsentManager(t, "did:key:zAlice", "uid-1", []string{"granted"}) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL)), - consentRequest(1, "did:key:zAlice"), []byte(`{"email":"alice@example.org"}`)) + resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(1, "did:key:zCaller"), []byte(`{"email":"alice@example.org"}`)) assert.Nil(t, resp.writtenBody, "granted consent should not modify the response") assert.Equal(t, 0, resp.writtenStatus) @@ -302,9 +345,11 @@ func TestIntegration_GrantedConsentPassthrough(t *testing.T) { func TestIntegration_NoGrantedConsentDenied(t *testing.T) { srv := newConsentManager(t, "", "uid-1", []string{"revoked"}) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL)), - consentRequest(2, "did:key:zAlice"), []byte(`{"email":"alice@example.org"}`)) + resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(2, "did:key:zCaller"), []byte(`{"email":"alice@example.org"}`)) assert.Equal(t, 403, resp.writtenStatus) assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) @@ -315,9 +360,11 @@ func TestIntegration_NoGrantedConsentDenied(t *testing.T) { func TestIntegration_UnknownSubjectDenied(t *testing.T) { srv := newConsentManager(t, "", "", nil) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zStranger") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL)), - consentRequest(3, "did:key:zStranger"), []byte(`{"email":"x@example.org"}`)) + resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(3, "did:key:zCaller"), []byte(`{"email":"x@example.org"}`)) assert.Equal(t, 403, resp.writtenStatus) assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) @@ -328,13 +375,16 @@ func TestIntegration_CustomDenyResponse(t *testing.T) { srv := newConsentManager(t, "", "uid-1", []string{"revoked"}) defer srv.Close() - cfg := baseConfig(srv.URL) + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() + + cfg := baseConfig(srv.URL, resolver.URL+"/resolve") cfg["deny_status_code"] = 451 cfg["deny_response_body"] = `{"error":"legally restricted"}` cfg["deny_response_content_type"] = "application/json" resp := runPluginCycle(t, marshalConfig(t, cfg), - consentRequest(4, "did:key:zAlice"), []byte(`{"secret":"x"}`)) + consentRequest(4, "did:key:zCaller"), []byte(`{"secret":"x"}`)) assert.Equal(t, 451, resp.writtenStatus) assert.Equal(t, `{"error":"legally restricted"}`, string(resp.writtenBody)) @@ -349,11 +399,14 @@ func TestIntegration_ConsentManagerError_FailOpen(t *testing.T) { })) defer srv.Close() - cfg := baseConfig(srv.URL) + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() + + cfg := baseConfig(srv.URL, resolver.URL+"/resolve") cfg["fail_open"] = true resp := runPluginCycle(t, marshalConfig(t, cfg), - consentRequest(5, "did:key:zAlice"), []byte(`{"data":"passes"}`)) + consentRequest(5, "did:key:zCaller"), []byte(`{"data":"passes"}`)) assert.Nil(t, resp.writtenBody, "fail-open should pass through on consent-manager error") assert.Equal(t, 0, resp.writtenStatus) @@ -367,39 +420,50 @@ func TestIntegration_ConsentManagerError_FailClosed(t *testing.T) { })) defer srv.Close() - cfg := baseConfig(srv.URL) + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() + + cfg := baseConfig(srv.URL, resolver.URL+"/resolve") cfg["fail_open"] = false resp := runPluginCycle(t, marshalConfig(t, cfg), - consentRequest(6, "did:key:zAlice"), []byte(`{"data":"denied"}`)) + consentRequest(6, "did:key:zCaller"), []byte(`{"data":"denied"}`)) assert.Equal(t, 403, resp.writtenStatus) assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) } -// TestIntegration_SubjectForwardedFromJWT verifies the subject from the JWT "sub" -// claim is forwarded to the consent-manager as the user email (asserted in the mock). -func TestIntegration_SubjectForwardedFromJWT(t *testing.T) { +// TestIntegration_OwnerNotRequestor verifies the subject the consent-manager is +// asked about is the RESOLVED DATA OWNER, not the caller (asserted in the mock). +func TestIntegration_OwnerNotRequestor(t *testing.T) { srv := newConsentManager(t, "did:key:zBob", "uid-bob", []string{"granted"}) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zBob") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL)), - consentRequest(7, "did:key:zBob"), []byte(`{"ok":true}`)) + // The caller is Alice; the resolver says the data belongs to Bob, and the + // mock asserts that Bob — not Alice — is the subject sent to the search. + resp := runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(7, "did:key:zAlice"), []byte(`{"ok":true}`)) assert.Nil(t, resp.writtenBody) } // TestIntegration_CustomJWTHeader verifies the plugin reads the JWT from a custom -// header when configured (subject still resolves and consent is granted). +// header when configured (the resolved owner's consent is granted). func TestIntegration_CustomJWTHeader(t *testing.T) { srv := newConsentManager(t, "custom-user", "uid-c", []string{"granted"}) defer srv.Close() + resolver := newOwnerResolver(t, "custom-user") + defer resolver.Close() - cfg := baseConfig(srv.URL) + cfg := baseConfig(srv.URL, resolver.URL+"/resolve") cfg["jwt_header_name"] = "X-Auth-Token" h := newMockRequestHeader() - h.Set("X-Auth-Token", "Bearer "+buildMockJWT(map[string]interface{}{"sub": "custom-user"})) + h.Set("X-Auth-Token", "Bearer "+buildMockJWT(map[string]interface{}{ + "verifiableCredential": map[string]interface{}{"issuer": itestConsumerDID}, + })) req := &mockRequest{id: 8, method: "POST", path: []byte("/api/v1/items"), header: h} resp := runPluginCycle(t, marshalConfig(t, cfg), req, []byte(`{"created":true}`)) @@ -412,10 +476,12 @@ func TestIntegration_CustomJWTHeader(t *testing.T) { func TestIntegration_ContextCleanupAfterCycle(t *testing.T) { srv := newConsentManager(t, "", "uid-1", []string{"granted"}) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() const id = uint32(999) - _ = runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL)), - consentRequest(id, "did:key:zAlice"), []byte(`{"data":"test"}`)) + _ = runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(id, "did:key:zCaller"), []byte(`{"data":"test"}`)) _, found := plugin.LoadRequestContext(integrationReqKey(id)) assert.False(t, found, "request context should be deleted after the response cycle") @@ -472,17 +538,19 @@ func newConsentManagerTokenService(t *testing.T, wantSubject, userID, selfDescri _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) + return httptest.NewServer(mux) } // ccConfig is a plugin config using participant client credentials (no static // token, no explicit provider_sd — both are obtained from the consent-manager). -func tokenServiceConfig(consentURL string) map[string]interface{} { +func tokenServiceConfig(consentURL, resolverURL string) map[string]interface{} { return map[string]interface{}{ - "consent_api_url": consentURL, - "consent_key": "itest-consent-key", - "token_service_url": consentURL + "/internal/tokens", - "jwt_claims_to_forward": []string{"sub"}, + "consent_api_url": consentURL, + "owner_resolver_url": resolverURL, + "consent_key": "itest-consent-key", + "token_service_url": consentURL + "/internal/tokens", } } @@ -493,9 +561,11 @@ 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() + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL)), - consentRequest(20, "did:key:zAlice"), []byte(`{"ok":true}`)) + resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(20, "did:key:zCaller"), []byte(`{"ok":true}`)) assert.Nil(t, resp.writtenBody, "granted consent via client credentials should pass through") } @@ -506,9 +576,11 @@ func TestIntegration_TokenServiceDenied(t *testing.T) { srv := newConsentManagerTokenService(t, "", "uid-1", "http://consent-facade:8080/participants/derived", []string{"revoked"}) defer srv.Close() + resolver := newOwnerResolver(t, "did:key:zAlice") + defer resolver.Close() - resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL)), - consentRequest(21, "did:key:zAlice"), []byte(`{"secret":"x"}`)) + resp := runPluginCycle(t, marshalConfig(t, tokenServiceConfig(srv.URL, resolver.URL+"/resolve")), + consentRequest(21, "did:key:zCaller"), []byte(`{"secret":"x"}`)) assert.Equal(t, 403, resp.writtenStatus) assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 04c52e6..3bc9f7c 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -118,21 +118,26 @@ type Config struct { // Defaults to DefaultJWTHeaderName ("Authorization"). JWTHeaderName string `json:"jwt_header_name,omitempty"` - // JWTClaimsToForward specifies which JWT claims to send to the consent API. - // For example: ["sub", "scope"]. Must include "sub" — the consent check - // resolves the data subject from the "sub" claim. + // JWTClaimsToForward lists the JWT claims the request phase decodes and keeps. + // An empty list decodes every claim. The claims identify the CONSUMER (see + // ConsumerClaim) for the contract lookup; they never identify the data owner. JWTClaimsToForward []string `json:"jwt_claims_to_forward,omitempty"` // ConsentAPIPrefix is the consent-manager API prefix prepended to endpoint // 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): + // OwnerResolverURL is the external OwnerResolver /resolve endpoint (required). + // The data owner is resolved from the RESPONSE DATA, never from 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"` + // checks consent per owner. + // + // It is required because the only alternative — treating the access token's + // "sub" as the data subject — asks whether the CALLER has consented, which + // establishes no link between the caller and the data being returned and so + // gates nothing (any subject with one granted consent becomes a universal + // reader). + OwnerResolverURL string `json:"owner_resolver_url"` // ConsentAPIHost overrides the HTTP Host header sent on consent-manager // calls. Needed when ConsentAPIURL points at an in-cluster gateway service @@ -244,10 +249,10 @@ func (c *Config) applyDefaults() { if c.ConsentAPIPrefix == "" { c.ConsentAPIPrefix = DefaultConsentAPIPrefix } - if c.OwnerResolverURL != "" && c.OwnerResolverTimeout == 0 { + if c.OwnerResolverTimeout == 0 { c.OwnerResolverTimeout = DefaultOwnerResolverTimeout } - if c.OwnerResolverURL != "" && c.ConsumerClaim == "" { + if c.ConsumerClaim == "" { c.ConsumerClaim = DefaultConsumerClaim } if c.TokenAudience == "" { @@ -310,14 +315,18 @@ 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) - } + // The resolver is the only source of data ownership, so a route without one + // cannot gate anything and must not load. + if c.OwnerResolverURL == "" { + return errors.New("config validation: owner_resolver_url is required — " + + "the data owner is resolved from the response data, and without a resolver the plugin cannot determine whose consent to check") + } + 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 != "" { diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index bb3abf1..6d5396e 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -28,7 +28,8 @@ import ( // validConfigJSON returns a minimal valid configuration JSON for testing. func validConfigJSON() map[string]interface{} { return map[string]interface{}{ - "consent_api_url": "https://consent.example.com/api", + "consent_api_url": "https://consent.example.com/api", + "owner_resolver_url": "https://owner-resolver.example.com/resolve", } } @@ -50,7 +51,7 @@ func TestParseConfig(t *testing.T) { }{ { name: "valid config with only required field applies defaults", - input: []byte(`{"consent_api_url": "https://consent.example.com/api"}`), + input: []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve"}`), check: func(t *testing.T, cfg *Config) { assert.Equal(t, "https://consent.example.com/api", cfg.ConsentAPIURL) assert.Equal(t, DefaultConsentAPITimeout, cfg.ConsentAPITimeout) @@ -66,6 +67,7 @@ func TestParseConfig(t *testing.T) { input: func() []byte { m := map[string]interface{}{ "consent_api_url": "http://localhost:8080/consent", + "owner_resolver_url": "http://localhost:9090/resolve", "consent_api_timeout": 10000, "jwt_header_name": "X-Auth-Token", "jwt_claims_to_forward": []string{"sub", "scope", "aud"}, @@ -78,6 +80,7 @@ func TestParseConfig(t *testing.T) { }(), check: func(t *testing.T, cfg *Config) { assert.Equal(t, "http://localhost:8080/consent", cfg.ConsentAPIURL) + assert.Equal(t, "http://localhost:9090/resolve", cfg.OwnerResolverURL) assert.Equal(t, 10000, cfg.ConsentAPITimeout) assert.Equal(t, "X-Auth-Token", cfg.JWTHeaderName) assert.Equal(t, []string{"sub", "scope", "aud"}, cfg.JWTClaimsToForward) @@ -351,6 +354,7 @@ func TestConfig_Validate(t *testing.T) { config: Config{ ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: DefaultConsentAPITimeout, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", JWTHeaderName: DefaultJWTHeaderName, DenyStatusCode: DefaultDenyStatusCode, DenyResponseBody: DefaultDenyResponseBody, @@ -366,11 +370,22 @@ func TestConfig_Validate(t *testing.T) { wantErr: true, errSubstr: "consent_api_url is required", }, + { + name: "missing owner_resolver_url fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "owner_resolver_url is required", + }, { name: "negative timeout fails", config: Config{ ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: -1, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", DenyStatusCode: DefaultDenyStatusCode, }, wantErr: true, @@ -398,7 +413,7 @@ func TestConsentFilter_ParseConf_Integration(t *testing.T) { p := &ConsentFilter{} t.Run("valid config returns *Config", func(t *testing.T) { - input := []byte(`{"consent_api_url": "https://consent.example.com/api"}`) + input := []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve"}`) conf, err := p.ParseConf(input) require.NoError(t, err) diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 735f79a..6f846cd 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -37,9 +37,6 @@ import ( // pluginName is the registered name for this plugin in APISIX configuration. const pluginName = "consent-filter" -// jwtSubjectClaim is the JWT claim key used to extract the subject identity. -const jwtSubjectClaim = "sub" - // nginxRequestIDVar is the Nginx variable ($request_id) holding a unique id // per HTTP request. Unlike the runner's per-RPC ID(), it is identical in the // RequestFilter (ext-plugin-pre-req) and ResponseFilter (ext-plugin-post-resp) @@ -95,6 +92,10 @@ func (c *ConsentFilter) ParseConf(in []byte) (interface{}, error) { // It extracts the JWT from the configured header, decodes the requested claims, // captures all request headers, and stores the context keyed by request ID // for later retrieval in ResponseFilter. +// +// The JWT is decoded, NOT verified (see internal/jwt): the claims are used only +// to name the consuming participant for the contract lookup, and the route MUST +// have an authentication plugin in front of this one that validates the token. func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r pkgHTTP.Request) { cfg, ok := conf.(*Config) if !ok { @@ -162,21 +163,27 @@ type responseOutcome struct { method string } -// ResponseFilter gates the upstream response on the data subject's consent. +// ResponseFilter gates the upstream response on the data owner's consent. // // The flow is: // 1. Correlate with the request phase and load (and delete) the stored context. -// 2. Build a ConsentRequest (the subject comes from the JWT "sub" claim). -// 3. Run the two-call consent check against the consent-manager. +// 2. Ask the OwnerResolver, from the RESPONSE DATA, whether consent is required +// and who the data owner(s) are. +// 3. Run the two-call consent check per resolved owner (deny_all: every owner +// must have a granted consent). // 4. Allow → pass the response through unchanged; deny → replace it with the // configured denial response. -// 5. On unresolved context or a consent-manager error, apply the fail policy -// (deny unless explicitly fail-open). +// 5. On unresolved context, a resolver error, or a consent-manager error, apply +// the fail policy (deny unless explicitly fail-open). +// +// The requestor's identity is NEVER used to determine ownership: the token's +// "sub" says who is asking, not whose data is being returned, so a check against +// it would let any subject holding one granted consent read everyone's data. // // Every decision is recorded to the audit sink (when enabled) before it is -// enforced. The check is a coarse allow/deny on the subject's consent and is -// independent of the response body, so — unlike a field-level filter — an empty -// or non-JSON personal-data response is still gated rather than passed through. +// enforced. The check is a coarse allow/deny and is independent of the response +// body's shape, so — unlike a field-level filter — an empty or non-JSON +// personal-data response is still gated rather than passed through. func (c *ConsentFilter) ResponseFilter(conf interface{}, w pkgHTTP.Response) { cfg, ok := conf.(*Config) if !ok { @@ -213,23 +220,16 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom return failOutcome(cfg, "no request context", key, nil) } + // Resolve the data owner(s) from the response DATA and check consent per + // owner. ParseConfig guarantees a resolver is configured. 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) - return checkConsent(cfg, key, consentClient, consentReq) + return c.evaluateWithResolver(cfg, w, key, reqCtx, consentClient) } // 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. +// response is denied. The requestor identity is never consulted for ownership. func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, key string, reqCtx *RequestContext, consentClient *consent.Client) responseOutcome { body, err := w.ReadBody() if err != nil { @@ -316,27 +316,6 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke 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 resp.Decision == consent.DecisionAllow { - decision = decisionAllow - } - return responseOutcome{ - decision: decision, - reason: resp.Reason, - requestID: key, - 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{ @@ -399,27 +378,6 @@ func recordAudit(cfg *Config, outcome responseOutcome) { }) } -// buildConsentRequest creates a ConsentRequest from the stored request context. -// The subject (used to look up consent) is taken from the JWT "sub" claim. -func buildConsentRequest(reqCtx *RequestContext) consent.ConsentRequest { - consentReq := consent.ConsentRequest{ - Resource: reqCtx.Path, - Method: reqCtx.Method, - Claims: reqCtx.JWTClaims, - } - - // Extract subject from JWT claims if available. - if reqCtx.JWTClaims != nil { - if sub, ok := reqCtx.JWTClaims[jwtSubjectClaim]; ok { - if subStr, ok := sub.(string); ok { - consentReq.Subject = subStr - } - } - } - - return consentReq -} - // denyResponse writes a denial response to the client using the configured // status code, body, and content type. func denyResponse(w pkgHTTP.Response, cfg *Config) { diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 6783f2d..844a92d 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -18,7 +18,6 @@ package plugin import ( - "consent-plugin/internal/consent" "encoding/json" "net/http" "net/http/httptest" @@ -43,7 +42,7 @@ func TestConsentFilter_ParseConf(t *testing.T) { }{ { name: "valid config returns parsed Config", - input: []byte(`{"consent_api_url": "https://consent.example.com"}`), + input: []byte(`{"consent_api_url": "https://consent.example.com", "owner_resolver_url": "https://resolver.example.com/resolve"}`), wantErr: false, }, { @@ -51,6 +50,11 @@ func TestConsentFilter_ParseConf(t *testing.T) { input: []byte(`{}`), wantErr: true, }, + { + name: "missing owner_resolver_url returns error", + input: []byte(`{"consent_api_url": "https://consent.example.com"}`), + wantErr: true, + }, { name: "nil input returns error", input: nil, @@ -112,11 +116,10 @@ type mockResponse struct { writtenStatus int } -func newMockResponse(id uint32, body []byte, contentType string) *mockResponse { +// newMockResponse builds a JSON upstream response carrying body. +func newMockResponse(id uint32, body []byte) *mockResponse { h := newMockHeader() - if contentType != "" { - h.Set("Content-Type", contentType) - } + h.Set("Content-Type", responseContentTypeJSON) return &mockResponse{id: id, header: h, body: body} } @@ -170,6 +173,20 @@ func newConsentManager(t *testing.T, userID string, statuses []string) *httptest w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) }) + // The participant registry, used to translate the consumer DID from the token + // into the self-description URL a contract names its parties by. + mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, + }) + }) + mux.HandleFunc("/v1/participants/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "selfDescriptionURL": "http://catalog/participants/provider", + }) + }) return httptest.NewServer(mux) } @@ -181,11 +198,71 @@ func newFailingConsentManager(status int) *httptest.Server { })) } -// newUncalledConsentManager fails the test if the consent-manager is contacted. +// newUncalledConsentManager fails the test if a CONSENT CHECK reaches the +// consent-manager. The participant registry is still served: mapping the +// consumer DID to a self-description is part of the contract lookup that +// precedes the check, and happens even when no check is performed. func newUncalledConsentManager(t *testing.T) *httptest.Server { t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Errorf("consent-manager must not be called (path %s)", r.URL.Path) + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, + }) + }) + mux.HandleFunc("/", func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("consent-manager must not be called for a consent check (path %s)", r.URL.Path) + }) + return httptest.NewServer(mux) +} + +// --- OwnerResolver mock (the source of data ownership) --- + +// resolverClaim is one (owner [x dataResource]) requirement in a mock /resolve reply. +type resolverClaim struct { + OwnerID string `json:"ownerId"` + DataResource string `json:"dataResource,omitempty"` +} + +// resolverResponse is the mock OwnerResolver's /resolve reply. +type resolverResponse struct { + ConsentRequired bool `json:"consentRequired"` + Claims []resolverClaim `json:"claims"` +} + +// ownedBy builds a resolve reply naming the given data owners (consent required). +func ownedBy(owners ...string) resolverResponse { + claims := make([]resolverClaim, 0, len(owners)) + for _, o := range owners { + claims = append(claims, resolverClaim{OwnerID: o}) + } + return resolverResponse{ConsentRequired: true, Claims: claims} +} + +// newOwnerResolver starts a mock OwnerResolver answering every /resolve with resp. +func newOwnerResolver(t *testing.T, resp resolverResponse) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("failed to encode resolve response: %v", err) + } + })) +} + +// newFailingOwnerResolver returns a resolver answering every call with status. +func newFailingOwnerResolver(status int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + })) +} + +// newUncalledOwnerResolver fails the test if the resolver is contacted. +func newUncalledOwnerResolver(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("owner resolver must not be called (path %s)", r.URL.Path) })) } @@ -193,12 +270,16 @@ func newUncalledConsentManager(t *testing.T) *httptest.Server { func boolPtr(b bool) *bool { return &b } -// newTestConfig creates a valid plugin Config pointing at the given consent-manager. -func newTestConfig(consentAPIURL string) *Config { +// newTestConfig creates a valid plugin Config pointing at the given +// consent-manager and OwnerResolver. +func newTestConfig(consentAPIURL, resolverURL string) *Config { return &Config{ ConsentAPIURL: consentAPIURL, ConsentAPIPrefix: DefaultConsentAPIPrefix, ConsentAPITimeout: DefaultConsentAPITimeout, + OwnerResolverURL: resolverURL, + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ConsumerClaim: DefaultConsumerClaim, JWTHeaderName: DefaultJWTHeaderName, ConsentKey: "test-consent-key", ParticipantToken: "test-participant-token", @@ -209,16 +290,23 @@ func newTestConfig(consentAPIURL string) *Config { } } -// storeSubject stores a request context carrying the given subject DID. -func storeSubject(id uint32, subject string) { +// storeRequest stores a request context for the given mock id. The consuming +// participant is named in the claims; the data owner comes from the resolver. +func storeRequest(id uint32) { StoreRequestContext(testReqKey(id), &RequestContext{ - Method: "GET", - Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:alice", - JWTClaims: map[string]interface{}{"sub": subject}, + Method: "GET", + Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:alice", + JWTClaims: map[string]interface{}{ + "verifiableCredential": map[string]interface{}{"issuer": testConsumerDID}, + }, }) } -const testSubjectDID = "did:key:zSubject" +// testOwnerDID is the data owner a mock resolver reports. +const testOwnerDID = "did:key:zOwner" + +// testConsumerDID is the requesting participant named in the token claims. +const testConsumerDID = "did:key:zConsumer" // --- ResponseFilter tests (coarse allow/deny gate) --- @@ -227,95 +315,142 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { name string setupContext func(id uint32) consentServer func(t *testing.T) *httptest.Server - configFn func(consentURL string) *Config + resolverServer func(t *testing.T) *httptest.Server + configFn func(cfg *Config) invalidConfig bool wantWrittenBody string wantWrittenStatus int wantNoWrite bool }{ { - name: "granted consent passes the response through", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, - consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"granted"}) }, - wantNoWrite: true, + name: "granted consent for the resolved owner passes the response through", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"granted"}) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + wantNoWrite: true, }, { name: "no granted consent denies with the default response", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, + setupContext: storeRequest, consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"revoked"}) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "unknown subject (404 on search) denies", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, + name: "unknown owner (404 on search) denies", + setupContext: storeRequest, consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "", nil) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "deny uses the custom status code and body", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, - consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"revoked"}) }, - configFn: func(consentURL string) *Config { - cfg := newTestConfig(consentURL) + name: "deny uses the custom status code and body", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"revoked"}) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + configFn: func(cfg *Config) { cfg.DenyStatusCode = 451 cfg.DenyResponseBody = `{"msg":"legally blocked"}` - return cfg }, wantWrittenBody: `{"msg":"legally blocked"}`, wantWrittenStatus: 451, }, { - name: "empty sub claim denies without contacting the consent-manager", - setupContext: func(id uint32) { storeSubject(id, "") }, + name: "consent not required allows without contacting the consent-manager", + setupContext: storeRequest, consentServer: newUncalledConsentManager, - // empty subject => CheckConsent returns deny before any HTTP call + resolverServer: func(t *testing.T) *httptest.Server { + return newOwnerResolver(t, resolverResponse{ConsentRequired: false}) + }, + wantNoWrite: true, + }, + { + name: "consent required but no owner resolved denies", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: func(t *testing.T) *httptest.Server { + return newOwnerResolver(t, resolverResponse{ConsentRequired: true}) + }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "consent-manager error with fail-open passes through", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, - consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, - wantNoWrite: true, + name: "resolved claim without an owner id denies", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: func(t *testing.T) *httptest.Server { + return newOwnerResolver(t, resolverResponse{ConsentRequired: true, Claims: []resolverClaim{{OwnerID: ""}}}) + }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "consent-manager error with fail-closed denies", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, - consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, - configFn: func(consentURL string) *Config { - cfg := newTestConfig(consentURL) - cfg.FailOpen = boolPtr(false) - return cfg - }, + name: "one denying owner denies the whole response (deny_all)", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newConsentManager(t, "uid-1", []string{"revoked"}) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy("did:key:zA", "did:key:zB")) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "missing request context with fail-open passes through", - setupContext: nil, - consentServer: newUncalledConsentManager, - wantNoWrite: true, + name: "resolver error with fail-open passes through", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: func(t *testing.T) *httptest.Server { return newFailingOwnerResolver(http.StatusInternalServerError) }, + wantNoWrite: true, }, { - name: "missing request context with fail-closed denies", - setupContext: nil, - consentServer: newUncalledConsentManager, - configFn: func(consentURL string) *Config { - cfg := newTestConfig(consentURL) - cfg.FailOpen = boolPtr(false) - return cfg - }, + name: "resolver error with fail-closed denies", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: func(t *testing.T) *httptest.Server { return newFailingOwnerResolver(http.StatusInternalServerError) }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "invalid config type passes through", - setupContext: func(id uint32) { storeSubject(id, testSubjectDID) }, - consentServer: newUncalledConsentManager, - invalidConfig: true, - wantNoWrite: true, + name: "consent-manager error with fail-open passes through", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + wantNoWrite: true, + }, + { + name: "consent-manager error with fail-closed denies", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, + { + name: "missing request context with fail-open passes through", + setupContext: nil, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + wantNoWrite: true, + }, + { + name: "missing request context with fail-closed denies", + setupContext: nil, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, + { + name: "invalid config type passes through", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + invalidConfig: true, + wantNoWrite: true, }, } @@ -325,18 +460,21 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { server := tt.consentServer(t) defer server.Close() + resolver := tt.resolverServer(t) + defer resolver.Close() var cfg interface{} - switch { - case tt.invalidConfig: + if tt.invalidConfig { cfg = "not-a-config" - case tt.configFn != nil: - cfg = tt.configFn(server.URL) - default: - cfg = newTestConfig(server.URL) + } else { + c := newTestConfig(server.URL, resolver.URL+"/resolve") + if tt.configFn != nil { + tt.configFn(c) + } + cfg = c } - resp := newMockResponse(1, nil, "") + resp := newMockResponse(1, []byte(`{"id":"urn:ngsi-ld:PersonalProfile:alice"}`)) if tt.setupContext != nil { tt.setupContext(resp.id) } @@ -359,16 +497,21 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { } } +// responseContentTypeJSON is the Content-Type of the simulated upstream responses. +const responseContentTypeJSON = "application/json" + func TestConsentFilter_ResponseFilter_ContextCleanup(t *testing.T) { clearContextStore() server := newConsentManager(t, "uid-1", []string{"granted"}) defer server.Close() + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() - cfg := newTestConfig(server.URL) + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") const id = uint32(200) - storeSubject(id, testSubjectDID) + storeRequest(id) - resp := newMockResponse(id, nil, "") + resp := newMockResponse(id, []byte(`{}`)) (&ConsentFilter{}).ResponseFilter(cfg, resp) _, found := LoadRequestContext(testReqKey(id)) @@ -379,67 +522,72 @@ func TestConsentFilter_ResponseFilter_DenySetsContentType(t *testing.T) { clearContextStore() server := newConsentManager(t, "uid-1", []string{"revoked"}) defer server.Close() + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() - cfg := newTestConfig(server.URL) + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") cfg.DenyResponseContentType = "text/plain" const id = uint32(201) - storeSubject(id, testSubjectDID) + storeRequest(id) - resp := newMockResponse(id, nil, "") + resp := newMockResponse(id, []byte(`{}`)) (&ConsentFilter{}).ResponseFilter(cfg, resp) assert.Equal(t, "text/plain", resp.header.Get("Content-Type")) assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus) } -func TestBuildConsentRequest(t *testing.T) { - tests := []struct { - name string - reqCtx *RequestContext - wantReq consent.ConsentRequest - }{ - { - name: "builds request with subject from sub claim", - reqCtx: &RequestContext{ - Method: "GET", - Path: "/api/users/1", - JWTClaims: map[string]interface{}{"sub": "did:key:z42", "scope": "read"}, - }, - wantReq: consent.ConsentRequest{ - Subject: "did:key:z42", - Resource: "/api/users/1", - Method: "GET", - Claims: map[string]interface{}{"sub": "did:key:z42", "scope": "read"}, - }, - }, - { - name: "builds request without JWT claims", - reqCtx: &RequestContext{Method: "POST", Path: "/api/data"}, - wantReq: consent.ConsentRequest{ - Resource: "/api/data", - Method: "POST", - }, - }, - { - name: "non-string sub claim is ignored", - reqCtx: &RequestContext{ - Method: "GET", - Path: "/api/test", - JWTClaims: map[string]interface{}{"sub": float64(123)}, - }, - wantReq: consent.ConsentRequest{ - Resource: "/api/test", - Method: "GET", - Claims: map[string]interface{}{"sub": float64(123)}, - }, - }, - } +// TestResponseFilter_OwnerNotRequestor is the regression test for the removed +// legacy mode: the consent that decides access must be the RESOLVED OWNER's, not +// the caller's. The resolver names Bob as the owner while the token's "sub" is +// Alice; the identifier search must ask about Bob. +func TestResponseFilter_OwnerNotRequestor(t *testing.T) { + clearContextStore() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.wantReq, buildConsentRequest(tt.reqCtx)) + const owner = "did:key:zBob" + var searchedSubjects []string + + mux := http.NewServeMux() + mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + searchedSubjects = append(searchedSubjects, body["email"]) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-bob"}) + }) + mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "consents": []map[string]string{{"status": "revoked"}}, }) - } + }) + mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, + }) + }) + server := httptest.NewServer(mux) + defer server.Close() + + resolver := newOwnerResolver(t, ownedBy(owner)) + defer resolver.Close() + + const id = uint32(202) + // The caller is Alice; the data belongs to Bob. + StoreRequestContext(testReqKey(id), &RequestContext{ + Method: "GET", + Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob", + JWTClaims: map[string]interface{}{"sub": "did:key:zAlice"}, + }) + + resp := newMockResponse(id, []byte(`{"id":"urn:ngsi-ld:PersonalProfile:bob"}`)) + (&ConsentFilter{}).ResponseFilter(newTestConfig(server.URL, resolver.URL+"/resolve"), resp) + + assert.Equal(t, []string{owner}, searchedSubjects, + "consent must be checked for the resolved data owner, never for the token subject") + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "the owner has no granted consent, so the caller's own consent must not unlock the data") } func TestConfig_IsFailOpen(t *testing.T) { diff --git a/review.md b/review.md new file mode 100644 index 0000000..d0d45b4 --- /dev/null +++ b/review.md @@ -0,0 +1,653 @@ +# Code Review — `consent-plugin` + +**Reviewer:** senior engineer review, whole repository +**Date:** 2026-08-27 +**Revision reviewed:** `463ed56` (branch `main`, clean tree) + +--- + +## 1. Executive summary + +`consent-plugin` is a small, well-groomed Go codebase (≈5 000 lines incl. tests) that +implements an APISIX external plugin gating personal-data responses on the data +subject's consent. Craftsmanship at the *file* level is high: every exported symbol +is documented, magic numbers are named constants, errors are wrapped, `golangci-lint` +(15 extra linters, incl. `gosec`) reports **0 issues**, `govulncheck` reports **no +vulnerabilities**, tests pass under `-race`, and CI/release plumbing is complete. + +The problems are at the *design and lifecycle* level, and they cluster in one place: +**the security semantics of the default configuration, and the behaviour of the +newer owner-resolver path.** In its documented default shape the plugin answers the +wrong question ("does the *caller* have any consent?" instead of "did the *data +owner* consent to *this* caller?"), and the code path that fixes this +(`owner_resolver_url`) has **zero test coverage** and several silent fail-open holes. +Secondarily, the request-context store is an unbounded in-memory map with no +eviction — a slow leak with a credential-retention angle — and the README documents +a configuration surface (`client_id`/`client_secret`) that **no longer exists in the +code**, so anyone following it deploys a gate that never authenticates and, at the +default `fail_open: true`, silently allows everything. + +**Verdict:** the code is production-*grade* but not production-*ready*. The +must-fix set is C-1 … C-4 plus H-1; those are days of work, not weeks, and none +require re-architecture. + +### Verification performed + +| Check | Result | +| --- | --- | +| `go vet ./...` (go1.26.7) | clean | +| `golangci-lint run ./...` (v2.13.1) | **0 issues** | +| `govulncheck ./...` | no vulnerabilities | +| `go test -race ./...` | all pass | +| `go test -coverpkg=./... ` total | **69.1 %** (see §5) | +| `docker compose up` | **cannot start** — missing file (M-6) | + +### Findings at a glance + +| ID | Severity | Finding | Location | +| --- | --- | --- | --- | +| C-1 | Critical | Default (legacy) mode checks the **requestor's** consent, not the data owner's — an authenticated caller can read any subject's data | `internal/plugin/consent.go:404` | +| C-2 | Critical | Any `granted` consent authorises access, regardless of which consumer/purpose it was granted for | `internal/consent/client.go:621` | +| C-3 | Critical | Credential cache key omits `token_service_url` → cross-participant token / provider-SD confusion between routes | `internal/consent/client.go:190` | +| C-4 | Critical | README + CLAUDE.md document a removed config surface (`client_id`/`client_secret`); following them yields a silently open gate | `README.md:51-80` | +| H-1 | High | Party-resolution failures in resolver mode are logged and ignored → resolver called with no parties → possible `consentRequired:false` → allow | `internal/plugin/consent.go:251,257` | +| H-2 | High | `fail_open` defaults to **true** on a security control | `internal/plugin/config.go:229` | +| H-3 | High | Unbounded request-context store: no TTL, no cap, no eviction; retains `Authorization` bearer tokens | `internal/plugin/context.go:53` | +| H-4 | High | Owner-resolver path (the sound mode) has **0 % test coverage** | `internal/plugin/consent.go:233` | +| H-5 | High | Serialised per-owner consent checks with no request budget and no claim cap → unbounded response latency | `internal/plugin/consent.go:284-311` | +| M-1 | Medium | Deny response inherits all upstream headers (incl. `Content-Length`, `Set-Cookie`, pagination counters) | `internal/plugin/consent.go:425` | +| M-2 | Medium | `Validate()` accepts a config that cannot possibly authenticate | `internal/plugin/config.go:286` | +| M-3 | Medium | Audit trail is at-most-once and silently droppable — floodable, and never flushed at shutdown | `internal/audit/audit.go:164` | +| M-4 | Medium | `consumerFromClaims` cannot traverse arrays — fails silently on ordinary VP tokens | `internal/plugin/consent.go:461` | +| M-5 | Medium | Subject DIDs and upstream error bodies go to unstructured stdout logs, unrated | throughout | +| M-6 | Medium | `docker compose up` cannot work — `apisix-config.yaml` absent, socket bind-mount wrong | `docker-compose.yaml:29` | +| M-7 | Medium | Security scanners are `continue-on-error` and pinned to `latest` | `.github/workflows/security-analysis.yml` | +| L-1…L-11 | Low | Dead code, doc drift, container hardening, dependency age, unbounded resolver payload, misc. | see §6 | + +--- + +## 2. Architecture assessment + +### What the design gets right + +* **Phase correlation via `$request_id`** (`consent.go:44-66`) is the correct call and + the reasoning is documented at the point of use. The runner's per-RPC `ID()` really + is not stable across `ext-plugin-pre-req` / `ext-plugin-post-resp`; getting this + wrong is the classic bug in two-phase APISIX plugins, and this code avoids it. +* **Ownership from the data, not the requestor.** The `ownerresolver` package and the + emphatic comments around it ("Parties are for CONTRACT identification only — never + for ownership") show the right threat model. This is the correct architecture. +* **Coarse allow/deny rather than field filtering.** Deliberately chosen and + justified (`consent.go:158-178`): an empty or non-JSON personal-data response is + still gated. Better than a redaction filter that silently misses a field. +* **Per-entry credential cache locking** (`client.go:180-188`) coalesces concurrent + first-requests onto one token fetch without a global lock across the HTTP call. + That is a genuinely good piece of concurrency design, and it is tested + (`TestCheckConsent_ConcurrentTokenFetchCoalesced`). +* **Audit decoupled from the decision path** — bounded queue, background batching, + best-effort export. The right shape for a sidecar-adjacent gate. +* **Credentials via env, not route config**, keeping secrets out of etcd + (`config.go:272-284`). Correct instinct, correctly documented. + +### Structural concerns + +1. **Two modes, one of them unsound, and the unsound one is the default.** + `owner_resolver_url` is optional; when unset the plugin silently falls back to the + "legacy" JWT-subject mode (C-1). The two modes have very different security + properties but the same config surface and the same log prefix, and the README + documents only the weaker one. The legacy + mode should be dropped, setting a resolver is required. + +2. **The response phase is a synchronous fan-out of unbounded size.** + In resolver mode one client response can trigger `1 + 1 + 3N` HTTP calls (parties + mapping, `/resolve`, then per owner: identifier search + consents lookup, plus + token refresh) — all sequential, all on `context.Background()`, while APISIX holds + the buffered response. There is no per-request deadline, no concurrency, no cap on + `N`, and no negative caching (H-5). + +3. **No decision caching anywhere.** Every single response re-runs the whole chain. + Consent state changes rarely; a short-TTL (owner, resource) → decision cache with + explicit invalidation would cut the hot-path cost by an order of magnitude. The + token and participant-SD caches show the pattern is understood — it just was not + applied to the decision itself. + +4. **`ext-plugin-post-resp` implications are undocumented.** Attaching this phase + forces APISIX to buffer the entire upstream response (`ReadBody()` is a blocking + extra-info RPC over the unix socket), which defeats streaming and makes large + responses a memory multiplier. The README should state the constraint and a + recommended `max` response size for gated routes. + +--- + +## 3. Critical findings + +### C-1 — Legacy mode verifies the consent of the *requestor*, not of the data owner + +`internal/plugin/consent.go:404-423` (`buildConsentRequest`), reached from +`evaluate()` whenever `owner_resolver_url` is empty — the documented default. + +```go +if sub, ok := reqCtx.JWTClaims[jwtSubjectClaim]; ok { consentReq.Subject = subStr } +``` + +The `sub` of the *access token* becomes the consent subject. The check therefore +answers "has the caller granted some consent?" — it never establishes any link +between the caller and the data the upstream is about to return. + +**Failure scenario.** Alice (`did:key:zAlice`) has granted a consent. Alice obtains a +valid token and requests `/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob`. +The plugin resolves *Alice's* user identifier, finds *Alice's* granted consent, and +returns **Bob's** personal data. Any subject with one granted consent becomes a +universal reader. The gate is a no-op against the very threat it exists for. + +Compounding this, the JWT signature is **not verified** (`internal/jwt/extractor.go:19-21` +documents the assumption that APISIX or the upstream did it). Nothing in the plugin, +the config validation, or the README enforces that an auth plugin is actually attached +to the route. On a route without one, `sub` is attacker-supplied and the check +collapses entirely. + +**Recommendation.** Remove legacy mode: require `owner_resolver_url`, fail `Validate()` when no resolver is configured. + +### C-2 — Any granted consent authorises access, regardless of consumer or purpose + +`internal/consent/client.go:597-635` (`hasGrantedConsent`). + +```go +for _, consent := range out.Consents { + if consent.Status != grantedStatus { continue } + if dataResource == "" { return true, nil } // ← any consent, any consumer + ... +} +``` + +The consent list is filtered only on `status` (and optionally `data[].resource`). +It is never filtered by the **consuming participant** or the **purpose/contract**, +even though the plugin has just gone to the trouble of resolving the consumer's +self-description URL for the resolver call and *has* it in hand. + +**Failure scenario.** Bob grants consent to participant *X* for purpose "insurance +quote". Participant *Y* — a different consumer, with no consent from Bob — requests +Bob's data through this gateway. `hasGrantedConsent` sees Bob's granted consent to +*X* and allows *Y*. Under GDPR terms the plugin authorises a processing purpose the +subject never agreed to; the consent record it relied on is evidence of the wrong +agreement. + +**Recommendation.** Pass the consumer self-description (and, where the contract model +supports it, the purpose/contract id) into `hasGrantedConsent` and require the +consent's own participant/purpose to match. Until that is possible, the `?receipt=true` +payload should be inspected for the consumer field and mismatches treated as deny. +This is the single highest-value correctness fix in the codebase. + +### C-3 — Credential cache key omits the token source → cross-participant confusion + +`internal/consent/client.go:190`: + +```go +func (c *Client) cacheKey() string { return c.baseURL + "|" + c.tokenAudience } +``` + +The cached entry holds **both** the participant access token and the derived provider +self-description (`client.go:264-305`), but the key contains neither +`tokenServiceURL`, nor the static `ParticipantToken`, nor `consentKey`. `TokenAudience` +defaults to the constant `"consent-manager"` for every route. + +**Failure scenario.** One APISIX instance fronts two provider tenants — routes A and B +— both pointing at the same consent-manager `baseURL`, each with its own +`token_service_url` (its own participant credential). Both hash to the identical +cache key `"|consent-manager"`. Whichever route warms the cache first +installs *its* token and *its* `selfDescriptionURL`; the other route then performs the +identifier search scoped to the **wrong provider** and the consents lookup **as the +wrong participant**. Decisions are silently wrong in both directions: denials for +subjects who did consent, and allows against the wrong provider's consent records. +The same collision occurs when only `consent_key` or a static `provider_sd` differs +(the early-return at `client.go:255` only covers the case where *both* static token +*and* static SD are set). + +**Recommendation.** Key the cache on the full credential identity — e.g. +`baseURL | apiPrefix | tokenAudience | tokenServiceURL | sha256(staticToken) | providerSD`. +Add a test with two clients differing only in `token_service_url` asserting they do +not share a token. This is a small fix with a large blast radius; treat as must-fix +before any multi-tenant deployment. + +### C-4 — README and CLAUDE.md document a configuration surface that no longer exists + +`README.md:51-80` (config table + env note), `README.md:40-49` (participant-auth +section), `README.md:86-99` (route example), `CLAUDE.md` ("Important Files"). + +The docs describe participant **client credentials**: + +| Documented | Actually in the code | +| --- | --- | +| `client_id`, `client_secret` | *removed* — no such fields in `Config` | +| `CONSENT_CLIENT_ID`, `CONSENT_CLIENT_SECRET` | *removed* — env vars are `CONSENT_KEY`, `CONSENT_TOKEN_SERVICE_URL`, `CONSENT_AUDIT_OTLP_ENDPOINT` | +| `POST /participants/login` | *removed* — `fetchToken` posts to `token_service_url` (OID4VP facade) | +| — | `token_service_url`, `token_audience` — **undocumented** | +| — | `owner_resolver_url`, `owner_resolver_timeout`, `service`, `consumer_claim` — **undocumented** (the entire sound mode!) | +| — | `consent_api_host` — **undocumented** | + +`Config` has no `UnknownFields` rejection, so `client_id`/`client_secret` in a route +JSON are **silently discarded** by `json.Unmarshal`. + +**Failure scenario.** An operator copies the README's `curl` example verbatim. The +config parses and validates successfully. At request time `credentials()` returns +`"consent client: no participant_token and no token_service_url configured"` → the +fail policy applies. With the example's `"fail_open":false` this is a total outage of +every gated route with only a log line to explain it. With the *documented default* +(`fail_open: true`) it is worse: **every request is allowed, the consent gate is +entirely bypassed, and nothing signals it** beyond one log line per request. A +documentation defect here is a security defect. + +**Recommendation.** Rewrite the README config table and route example against +`internal/plugin/config.go`; document `owner_resolver_url` as the recommended mode +with an example; refresh `CLAUDE.md` (it also still lists an `internal/filter` +package that does not exist and omits `internal/audit` and `internal/ownerresolver`). +Add a CI check that every `json:` tag in `Config` appears in the README table — doc +drift on a security control needs a machine, not discipline. + +--- + +## 4. High-severity findings + +### H-1 — Party-resolution failures are logged and ignored, then the resolver's answer is trusted + +`internal/plugin/consent.go:249-262`: + +```go +if consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(...); sdErr != nil { + log.Printf(...) // ← swallowed +} else { resolveParties.Consumer = consumerSD } +if providerSD, sdErr := consentClient.ProviderSelfDescription(...); sdErr != nil { + log.Printf(...) // ← swallowed +} else { resolveParties.Provider = providerSD } +``` + +Both failures leave `resolveParties` empty; `Parties.IsZero()` then **omits the field +entirely** from the `/resolve` request (`ownerresolver/client.go:140-142`). The plugin +proceeds to trust whatever the resolver returns — including +`consentRequired: false`, which is an unconditional **allow** (`consent.go:273-275`). + +**Failure scenario.** The consent-manager is briefly unreachable, or the cached +participant token has been revoked. `ParticipantSelfDescriptionByDID` returns +`errParticipantUnauthorized` — and note it has **no 401-refresh-and-retry** of its own, +unlike `CheckConsent` (`client.go:340-389` calls `credentials(ctx, false)`), so a stale +token is terminal for the mapping. The plugin then asks the resolver "who owns this +payload?" with no parties at all. A resolver that cannot identify a contract and +answers `consentRequired: false` (a plausible, arguably correct answer for "no +contract governs this exchange") causes personal data to be released. A fail-closed +design has a fail-open seam in the middle of it. + +**Recommendation.** Treat both resolution failures as `failOutcome(...)` — the same +policy already applied to a resolver error. If a degraded mode is genuinely wanted, +make it explicit (`allow_unidentified_parties`), default it off, and never let an +unidentified-party `/resolve` result reach the allow branch. Add the 401-refresh +retry to `ParticipantSelfDescriptionByDID`, and cache negative lookups briefly so a +misconfigured DID does not re-fetch the whole participant list per request. + +### H-2 — `fail_open` defaults to `true` + +`internal/plugin/config.go:228-233`. Every unresolved situation — consent-manager +down, missing request context, missing credentials, resolver error, unreadable body — +becomes **allow** unless the operator explicitly sets `fail_open: false`. + +For an availability-shaped filter that default is defensible. For a **consent gate on +personal data** it inverts the safe default: the failure mode of the security control +is "release the data", and it is reached by *omission*. Combined with C-4 the two +compose into a silent full bypass. + +**Recommendation.** Flip the default to fail-closed (a `major` semver bump — the +repo's label-driven release process handles this cleanly), keep `fail_open: true` +available as a deliberate, documented opt-out, and log a warning at `ParseConf` when +it is enabled. At minimum, distinguish the reasons: a consent-manager timeout is a +plausible fail-open case; *missing credentials* and *missing request context* never are. + +### H-3 — Unbounded request-context store; retains bearer tokens + +`internal/plugin/context.go:53` — a package-level `sync.Map`, written in +`RequestFilter` and deleted only by `LoadAndDeleteRequestContext` in `ResponseFilter`. +There is **no TTL, no size cap, no eviction sweep, and no gauge**. + +**Failure scenario.** Any request whose response phase never runs leaks one entry +permanently: client disconnects before the upstream responds; upstream connect +timeout; a preceding APISIX plugin short-circuits the request after `pre-req`; +`ext-plugin-post-resp` misconfigured on one of several routes; the runner restarting +between phases. The runner is a long-lived process, so the map grows monotonically +until OOM. It is also remotely drivable: open connections, send the request, abort +before the response — an unauthenticated memory-exhaustion primitive. + +Each leaked entry holds `RequestContext.Headers`, which is a **copy of every request +header including `Authorization: Bearer `** (`consent.go:110-116`) — so the leak +is a leak of credentials retained indefinitely in process memory. And `Headers` is +never read: the only consumer is `len(rc.Headers)` in `String()` +(`context.go:107`). It is pure cost and pure risk. + +**Recommendation.** (a) Delete the `Headers` field and its capture loop — dead weight +holding secrets. (b) Store `{ctx, insertedAt}` and add a janitor goroutine evicting +entries older than a bounded lifetime (a few seconds beyond the upstream timeout), +plus a hard cap that rejects/evicts oldest on overflow. The runner already pulls in +`ReneKroon/ttlcache/v2` transitively if a library is preferred. (c) Export the +store size so the leak is observable. + +### H-4 — The owner-resolver path has zero test coverage + +Measured with `go test -coverpkg=./... ./...` (the repo's own `make test-cover` omits +`-coverpkg`, so cross-package integration coverage is not attributed at all — the +53.9 % it prints for `internal/plugin` is an artefact, the true figure is higher): + +| Symbol | Coverage | +| --- | --- | +| `plugin.evaluateWithResolver` | **0.0 %** | +| `plugin.consumerFromClaims` | **0.0 %** | +| `plugin.resourceOrPath` | **0.0 %** | +| `consent.ParticipantSelfDescriptionByDID` | **0.0 %** | +| `consent.decodeParticipants` | **0.0 %** | +| `consent.ProviderSelfDescription` | **0.0 %** | +| `plugin.claimKeysToDecode` | 40.0 % | +| **total (all packages)** | **69.1 %** | + +Every finding in C-3, H-1, H-5 and M-4 lives in that untested region. The legacy +mode — the one that is architecturally unsound — is the one with good integration +coverage (11 end-to-end cases in `internal/integration`). `internal/ownerresolver` +itself is tested in isolation (82.9 %), but nothing exercises the plugin↔resolver↔ +consent-manager composition. + +**Recommendation.** Extend `internal/integration` with a resolver-mode harness: a +mock `/resolve`, multi-owner `deny_all` (one owner denies → whole response denied), +`consentRequired: false`, empty `claims`, a claim with an empty `ownerId`, resolver +5xx/timeout under both fail policies, and party-resolution failure (H-1). Add +`-coverpkg=./...` to `make test-cover` and to `.github/workflows/tests.yml`, and gate +CI on a coverage floor so this cannot regress silently. + +### H-5 — Serialised per-owner checks, no request budget, no claim cap + +`internal/plugin/consent.go:284-311`. The loop over `result.Claims` performs one full +two-call consent check per distinct `(owner, dataResource)` pair, sequentially, each +on `context.Background()` with its own `consent_api_timeout`. + +**Failure scenario.** A collection endpoint returns 200 entities with 200 distinct +owners. The plugin issues ~400 sequential HTTP calls; at the default 5 s per-call +timeout the worst case is ~2 000 s of held-open response while APISIX buffers the +body. Long before that, the client and APISIX time out, but the runner's goroutine +keeps working and its connections stay open — a small number of such requests +saturates the runner and the consent-manager. Any caller who can reach a +list endpoint can trigger it; no authentication beyond the ordinary token is needed. + +Related inefficiencies in the same loop: the dedup key is `(owner, dataResource)`, so +the same owner with two resources performs the identifier search **twice**; there is +no negative caching of "unknown subject"; and no decision cache (see §2.3). + +**Recommendation.** (a) Derive one `context.WithTimeout` for the whole response phase +and pass it to the resolver and every consent call, so the total is bounded and +cancellation propagates. (b) Cap the number of distinct claims checked (configurable, +e.g. `max_owners_per_response`) and fail closed above it. (c) Run the per-owner +checks with bounded concurrency (`errgroup.WithContext`, limit ~8) and short-circuit +on the first deny. (d) Memoise `subject → userIdentifier` for the duration of the +request. + +--- + +## 5. Test suite assessment + +**Strengths.** `-race` in both `make test` and CI. Table-driven subtests are the norm +(`config_test.go`, `consent_test.go`), matching the project convention. The +`internal/integration` package is a genuine end-to-end harness — real `ParseConf` → +`RequestFilter` → `ResponseFilter` against `httptest` consent-managers — not a mock +theatre; 11 scenarios cover pass-through, deny, unknown subject, custom deny +response, both fail policies, custom JWT header, context cleanup and the token +service. `internal/jwt` is at 100 %. `TestCheckConsent_ConcurrentTokenFetchCoalesced` +tests the coalescing invariant rather than the implementation. This is above-average +test discipline. + +**Gaps.** + +* **H-4**: the entire owner-resolver path is untested. Highest priority. +* **Coverage is mis-measured.** `make test-cover` and `tests.yml` omit `-coverpkg=./...`, + so the integration package's coverage of `internal/plugin` is discarded. The printed + numbers understate reality and, worse, make the *real* gaps (0 % functions) look + like measurement noise. Fix the flag; add a floor. +* **No coverage gate in CI.** Coverage is uploaded as an artefact and never asserted. +* **Package-level cache pollution across tests.** `credCache`, `participantSDCache` + and `emitters` are package globals with no test reset hook. Tests currently pass + only because `httptest` allocates a distinct `baseURL` per server; a future test + reusing a URL, or `t.Parallel()`, will produce order-dependent flakes. Add an + exported-for-test reset (or key the caches off an injectable struct). +* **Mocks diverge from the real runner in a load-bearing way.** `mockResponse.Write` + *replaces* `writtenBody` (`integration_test.go:154-157`) whereas the real + `Response.Write` *appends* to a buffer; `mockResponse.Header()` never affects a + `HasChange()`-equivalent. So no test can observe M-1 (header leakage on deny) or the + `Content-Length` question, and no test would catch a double-write regression. + Consider a fake that mirrors `internal/http.Response` semantics. +* **No `Content-Length`/header assertions on the deny path**, no test for a body + larger than the resolver limit, no test for `$request_id` unavailable in only one + phase, and no benchmark or load test despite H-5 being a latency finding. +* `TestConfig_IsFailOpen` lives in `consent_test.go` while the rest of the config + tests are in `config_test.go` — minor misfiling. + +--- + +## 6. Medium and low findings + +### M-1 — Deny response inherits all upstream headers + +`internal/plugin/consent.go:425-433` sets `Content-Type` and the status, writes the +deny body, and touches nothing else. Every other upstream response header survives +into the 403. + +* **`Content-Length`** still advertises the upstream body's length while the body is + now the 43-byte deny JSON. Whether the client sees a truncated/hung response + depends on whether APISIX recomputes it when `ext-plugin-post-resp` replaces a body + — **I could not verify this without a live APISIX**, and no test covers it. It is + the first thing to check in an end-to-end run. +* **Information leak, verified by inspection:** `Set-Cookie`, `ETag`, `Last-Modified`, + `Link`, and application headers such as `X-Total-Count` / `NGSILD-Results-Count` + reach a client that was just denied the data. A denied caller can read pagination + counts and entity versions — a side channel around the gate. + +**Fix:** on deny, delete the upstream headers before writing (whitelist what may +survive), and set `Content-Length` explicitly. Add an assertion once the mock supports it. + +### M-2 — `Validate()` accepts a configuration that cannot authenticate + +`internal/plugin/config.go:286-336` validates URLs, timeout and status-code ranges, +and the audit endpoint, but never checks that *some* participant credential exists +(`participant_token` **or** `token_service_url`). The README even documents this as +intentional ("None are enforced at parse time"). Combined with H-2 the result is a +route that loads cleanly and allows everything. `owner_resolver_timeout` and +`participant_token_ttl` are also unvalidated (unlike `consent_api_timeout`), and +`consent_api_prefix` is concatenated unchecked in `endpoint()` +(`client.go:637-639`) — a prefix without a leading `/` silently produces a malformed +URL. **Fix:** require a credential source at parse time; range-check the other +numeric fields; normalise/validate the prefix. + +### M-3 — Audit trail is silently droppable and never flushed + +`internal/audit/audit.go:164-172`. The queue is bounded at 2048 and `Emit` drops on +overflow with only a rate-limited log line — deliberate and correct *for the request +path*, but it means the compliance record is at-most-once and **an attacker can +suppress the record of their own access by generating load**. Also: + +* `main()` never calls `Shutdown()`, so up to `defaultFlushInterval` (2 s) of + decisions are lost on every runner restart/redeploy. `Shutdown` exists and is + tested; wire a `SIGTERM` handler. +* `Get()` caches emitters by `endpoint|serviceName` but **not** by `Timeout` + (`audit.go:118-134`), so the first route's timeout silently wins for all others. +* In resolver mode only the **first denying** owner is recorded, and an allow records + no owners at all (`consent.go:305-313`, `:315`) — so the audit log cannot answer + "whose consent was checked?", which is the question an audit log exists to answer. +* `consent.reason` carries `truncateBody()` output from consent-manager errors + (`client.go:666-672`), so upstream error bodies — potentially containing identifiers + — land in the audit sink. +* No OTLP authentication headers are configurable. + +**Fix:** record every checked `(owner, resource, decision)` per request; add a +`SIGTERM` flush; include `Timeout` in the emitter key; sanitise `reason` before +export; expose the dropped counter as a metric so suppression is detectable. + +### M-4 — `consumerFromClaims` cannot traverse arrays + +`internal/plugin/consent.go:461-482` walks a dotted path through +`map[string]interface{}` only. The default path is +`verifiableCredential.issuer` (`config.go:37-43`), but a Verifiable Presentation +commonly carries `verifiableCredential` as a **JSON array**. The type assertion +fails, `""` is returned, no error is logged from this function, and the consumer is +simply absent — feeding directly into H-1's allow seam. **Fix:** support array +indexing (`verifiableCredential[0].issuer` or implicit first-element traversal), and +distinguish "path not configured" from "path did not resolve" so the latter can be +treated as a failure. + +### M-5 — Unstructured logging of personal identifiers, unrated + +`log.Printf` with a hand-written `[consent-filter]` prefix appears ~15 times across +`plugin/` and `audit/`. Consequences: (a) the runner ships `pkg/log` (zap) whose level +configuration therefore does not apply — these lines cannot be filtered or +suppressed; (b) messages carry subject DIDs (`consent.go:251`) and upstream error +bodies, i.e. personal data in stdout with no retention policy, which is exactly what +the OTLP audit path was built to avoid; (c) there is no rate limiting, so a broken +consent-manager emits one line per request. **Fix:** switch to the runner's logger +with levels, drop or hash identifiers in non-audit logs, and rate-limit the +per-request failure paths. + +### M-6 — The documented local dev setup cannot start + +`docker-compose.yaml:29` mounts `./apisix-config.yaml`, which **does not exist in the +repository**; Docker will create a *directory* at that path and APISIX will fail to +parse its config. Additionally, both services bind-mount `/tmp/runner.sock` — a +socket file that does not exist at compose time, so Docker again creates a directory +and the runner cannot bind. The conventional fix is to share a *directory* (or named +volume) and put the socket inside it. `version: "3.8"` is also obsolete under Compose +v2. Since `README.md:121-124` advertises `docker compose up --build` as the dev workflow, +this is the first thing a new contributor hits. **Fix:** commit an +`apisix-config.yaml` with the `ext-plugin` wiring, switch to a shared socket +directory, drop `version:`, and add the consent-manager mock + otel-collector so the +stack is actually exercisable. + +### M-7 — Security scans cannot fail the build; tool versions unpinned + +`.github/workflows/security-analysis.yml` runs both `govulncheck` and `gosec` with +`continue-on-error: true`, so findings are informational only — a known-vulnerable +dependency merges cleanly. `gosec` is installed from `@latest` and +`golangci-lint-action` uses `version: latest`, making CI non-reproducible and +supply-chain-exposed; note the Gitea pipeline pins `v2.1.6`, so the two CIs can +disagree about whether the code lints. **Fix:** pin both tools; let `govulncheck` fail +the PR on a fixable vulnerability (allow-list with expiry for the rest); add +`go mod verify` and dependency review. + +### Low + +* **L-1 — Dead code from a removed field-filtering design.** `DecisionFilter`, + `Decision.IsValid`, `ConsentResponse.Validate`, `ConsentResponse.DeniedFields`, + `ConsentRequest.ResponseFields` and `ConsentRequest.Claims` + (`internal/consent/models.go:36-107`) are unreferenced by production code — they are + tested, which makes the coverage number flatter. Their `json:` tags describe a + `POST /check` API that the two-call client never calls, so the file actively + misleads. `ownerresolver.Claim.Selector`/`.Participant` and `Result.Scheme` are + decoded and never read. `plugin.LoadRequestContext` and `plugin.DeleteRequestContext` + are exported and unused. `RequestContext.Headers` — see H-3. Delete all of it. +* **L-2 — Package doc contradicts behaviour.** `internal/consent/models.go:19-21` and + `internal/plugin/config.go:19-20` still describe "filtering personal data" / + "allowed, denied, or filtered"; the plugin does coarse allow/deny. `CLAUDE.md` lists + an `internal/filter` package that does not exist, describes `/participants/login` + client credentials (see C-4), and omits `internal/audit` and `internal/ownerresolver`. +* **L-3 — Container hardening.** The runtime image (`Dockerfile:20-30`) runs as + **root** with no `USER`, on `alpine:3.19` (past end-of-support), with no + `HEALTHCHECK` and no `.dockerignore` (so `COPY . .` pulls `.git`, invalidating the + build cache on every commit). Add a non-root user, bump the base, add + `.dockerignore`. +* **L-4 — Dependency age.** Direct deps are stale: `testify` 1.8.4 → 1.12.1, + `api7/ext-plugin-proto` v0.6.0 → v0.6.1. `apisix-go-plugin-runner` is pinned at + v0.5.0 (its own transitive tree — zap 1.17, flatbuffers 2.0.0, grpc 1.38 — is + years old); worth tracking whether the runner is still maintained, since a plugin + whose runner is abandoned is a strategic risk. No Renovate/Dependabot config. +* **L-5 — `HasChange()` is forced true on every resolver-mode allow.** + `evaluateWithResolver` calls `w.Header()` to read `Content-Type` + (`consent.go:239-242`), which lazily initialises `hdr` and therefore makes the + runner's `HasChange()` return true (`internal/http/response.go:207`) even when + nothing was modified. Every gated response then travels the "response was + modified" path back to APISIX with an empty header diff. Probably benign, entirely + untested; read the `Content-Type` from the stored `RequestContext` or from + `r.rawHdr` instead. +* **L-6 — No metrics.** For a component that can deny production traffic there is no + counter for allow/deny/fail-open, no consent-manager latency histogram, no + context-store gauge, no audit-drop counter. Operationally this is flying blind; + logs are the only signal, and they are unstructured (M-5). +* **L-7 — `ParticipantTokenTTL` overflow.** `time.Duration(cfg.ParticipantTokenTTL) * time.Second` + (`consent.go:349`) overflows for absurd values; unvalidated (see M-2). +* **L-8 — Repo hygiene.** No `SECURITY.md` (vulnerability reporting path) and no + `CODEOWNERS` for a repo that gates personal-data access. `CONTRIBUTING.md` and the + workflow set are otherwise good. +* **L-9 — Local toolchain friction.** `go.mod` requires `go 1.26` while the system Go + is 1.22 and `GOTOOLCHAIN` cannot download; contributors need a pre-cached 1.26 + toolchain (this review used `go1.26.7` from the module cache). Worth a line in + `CONTRIBUTING.md`. +* **L-10 — No size cap on the payload forwarded to the OwnerResolver.** + `internal/plugin/consent.go:234` reads the whole upstream body, then + `ownerresolver.Resolve` (`internal/ownerresolver/client.go:131-170`) runs + `json.Valid(payload)` over it and `json.Marshal` copies it again into the request + envelope as a `json.RawMessage`. Peak footprint is therefore roughly *3×* the body + size per in-flight request, on top of APISIX's own buffering of the response — so a + handful of concurrent large-collection responses can drive the runner's memory well + past what the response size suggests. Unrelated to transport security: cluster mTLS + does not bound the copy. **Fix:** add a `max_resolve_body_bytes` limit and fail + closed above it rather than forwarding, and stream or reference the body instead of + embedding it once a limit exists. +* **L-11 — A non-JSON body is indistinguishable from no body at all.** + `internal/ownerresolver/client.go:132-135`: when `json.Valid(payload)` fails, the + envelope is sent with `encoding: "none"`, exactly as it is when there was no body. + The resolver cannot tell "this response carried a payload I could not parse" from + "this response had no payload", so it resolves ownership from the resource + descriptor alone. A malformed-but-personal payload (a truncated write, a + content-type mismatch, an upstream returning XML or NDJSON on a route declared + JSON) is therefore judged without ever being inspected. **Fix:** distinguish the + two cases in the envelope — e.g. a third encoding value, or `encoding: "opaque"` + with the content type — so the resolver can fail closed on an unparseable payload + instead of silently falling back. + +--- + +## 7. Prioritised action plan + +**Must fix before production** + +1. **C-4** — rewrite README + CLAUDE.md against the real config; add a doc-drift CI + check. *(Cheapest fix, prevents the silent-bypass deployment.)* +2. **C-1** — make owner-resolver mode mandatory (or legacy mode a loud, explicit + opt-in); document JWT verification as a hard route prerequisite. +3. **C-2** — scope the consent match to the consuming participant and purpose. +4. **C-3** — include the token source in the credential cache key; add the + two-participant regression test. +5. **H-1** — fail closed when the consumer or provider cannot be resolved; add the + 401-refresh retry to `ParticipantSelfDescriptionByDID`. +6. **H-2** — default `fail_open` to false; **M-2** — require a credential source at + parse time. +7. **H-3** — drop `RequestContext.Headers`; add TTL + cap + size gauge to the context store. + +**Before scale / next iteration** + +8. **H-4** — resolver-mode integration tests; `-coverpkg=./...` plus a CI coverage floor. +9. **H-5** — one request-scoped deadline, bounded concurrency, claim cap, identifier memoisation. +10. **M-1** — strip upstream headers on deny; verify `Content-Length` end-to-end against a real APISIX. +11. **M-3** — audit every checked owner; flush on `SIGTERM`; sanitise `reason`. +12. **M-6** — make `docker compose up` actually work; **M-7** — pin and enforce the scanners. +13. **L-6** — add Prometheus metrics for decisions, latency, drops and store size. + +**Cleanup** + +14. **L-1/L-2** — delete the dead field-filtering model and fix the stale package docs. +15. **L-3/L-4/L-8/L-9** — non-root container, base-image bump, `.dockerignore`, + dependency refresh + Renovate, `SECURITY.md`, `CODEOWNERS`, toolchain note. +16. **L-10/L-11** — cap the body forwarded to the OwnerResolver, and distinguish + "non-JSON body" from "no body" in the resolve envelope. + +--- + +## 8. Closing note + +The engineering hygiene here is genuinely good — the comments explain *why* rather +than *what*, the `$request_id` correlation and the per-entry credential locking show +real care, and the linter/CI/release setup is more complete than most projects of this +size. The gap is that the codebase is mid-migration: an older, unsound design +(requestor-subject consent) is still the default and still the only documented one, +while the sound design (owner-resolver) is present, undocumented, and untested. Most +of the critical findings are consequences of that unfinished transition rather than +of careless code. Finishing the migration — making resolver mode the only mode, +documenting it, testing it, and failing closed throughout — resolves C-1, C-4, H-1, +H-2 and H-4 together, and would move this from "promising" to "trustworthy". From 06fe591106854b20207ff13f8e518bbe4892feba Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:42:50 +0200 Subject: [PATCH 03/41] fix(consent): scope the consent match to the consuming participant and purpose (C-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasGrantedConsent filtered the consent list on `status` alone (plus, optionally, `data[].resource`). It never checked WHO the consent was granted to, even though the plugin had already resolved the consumer's self-description URL for the resolver call and was holding it. So: Bob grants consent to participant X for the purpose "insurance quote". Participant Y — a different consumer, with no consent from Bob — requests Bob's data through the gateway. The plugin saw Bob's granted consent to X and let Y through. Under GDPR terms it authorised a processing purpose the subject never agreed to, on the evidence of an entirely different agreement. A consent is now matched only when all of these hold: - status is "granted"; - it was granted to the consuming participant named in the request; - it covers the purpose, when the resolver could name one; - it covers the data resource, when the check is resource-scoped. Supporting changes: - `ConsentRequest` gains `Consumer` (required) and `Purpose` (optional). `CheckConsent` denies outright when no consumer is identified, rather than falling back to "does this subject have any consent at all?". - The receipt is decoded into a `consentRecord` that reads the consumer from either `consumer` or `dataConsumer`, in either the bare-string or the embedded object shape, matching on selfDescriptionURL / _id / did. A record that names no consumer matches nothing — the plugin cannot tell whose agreement it is, and guessing is what the finding is about. - `ownerresolver.Claim` gains `purpose`, so the resolver can name the contract purpose governing a claim; absent, only the consumer match applies. - Deny reasons now say which scope failed, so the audit log distinguishes "consented to someone else" from "did not consent to this resource". Tests: consumer-scoping cases in the client decision matrix (granted to another consumer, consent naming no consumer, request without a consumer) and a plugin-level regression test proving the wiring end to end. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 178 ++++++++++++++++++----- internal/consent/client_test.go | 93 +++++++++--- internal/consent/models.go | 13 ++ internal/integration/integration_test.go | 52 ++++--- internal/ownerresolver/client.go | 5 + internal/plugin/consent.go | 6 + internal/plugin/consent_test.go | 113 +++++++++----- 7 files changed, 354 insertions(+), 106 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index 30cfaaf..21330c6 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -129,7 +129,8 @@ type ClientConfig struct { // /participants/me then yields the provider selfDescriptionURL. Tokens are cached // package-wide (keyed by the full credential identity, see cacheKey) and // refreshed on expiry or a 401. Access is allowed iff a returned consent is -// "granted". +// "granted" AND was granted to the consuming participant named in the request +// (see hasGrantedConsent). type Client struct { baseURL string host string @@ -240,11 +241,16 @@ func (c *Client) CheckConsent(ctx context.Context, req ConsentRequest) (*Consent if req.Subject == "" { return &ConsentResponse{Decision: DecisionDeny, Reason: "no subject in request"}, nil } + // Without a named consumer the check degenerates into "does this subject have + // any consent at all?", which authorises the wrong agreement. Deny instead. + if req.Consumer == "" { + return &ConsentResponse{Decision: DecisionDeny, Reason: "no consuming participant identified"}, nil + } - resp, err := c.check(ctx, req.Subject, req.DataResource, false) + resp, err := c.check(ctx, req, 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, req.DataResource, true) + resp, err = c.check(ctx, req, true) } if errors.Is(err, errParticipantUnauthorized) { // Still unauthorized (or a static token was rejected): surface a plain error. @@ -254,15 +260,16 @@ 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. 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 before use. The check is scoped by req: a granted consent counts only if +// it was granted to req.Consumer and, when set, covers req.DataResource and +// req.Purpose. +func (c *Client) check(ctx context.Context, req ConsentRequest, forceLogin bool) (*ConsentResponse, error) { token, providerSD, err := c.credentials(ctx, forceLogin) if err != nil { return nil, err } - userIdentifier, found, err := c.resolveUserIdentifier(ctx, subject, providerSD, token) + userIdentifier, found, err := c.resolveUserIdentifier(ctx, req.Subject, providerSD, token) if err != nil { return nil, err } @@ -270,17 +277,28 @@ func (c *Client) check(ctx context.Context, subject, dataResource string, forceL return &ConsentResponse{Decision: DecisionDeny, Reason: "no user identifier for subject"}, nil } - granted, err := c.hasGrantedConsent(ctx, token, userIdentifier, dataResource) + granted, err := c.hasGrantedConsent(ctx, token, userIdentifier, req) 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: noGrantedConsentReason(req)}, nil +} + +// noGrantedConsentReason explains which scope the deny was decided at, so the +// audit record distinguishes "this subject consented to someone else" from +// "this subject did not consent to this resource". +func noGrantedConsentReason(req ConsentRequest) string { + reason := "no granted consent for consumer " + req.Consumer + if req.Purpose != "" { + reason += " and purpose " + req.Purpose } - return &ConsentResponse{Decision: DecisionDeny, Reason: "no granted consent"}, nil + if req.DataResource != "" { + reason += " covering resource " + req.DataResource + } + return reason } // credentials resolves the participant token and provider self-description, @@ -619,24 +637,123 @@ func (c *Client) resolveUserIdentifier(ctx context.Context, subject, providerSD, } // 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. +// ?receipt=true form returns the raw consents, each carrying its status, the +// consumer it was granted to, the purposes it covers 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"` + Consents []consentRecord `json:"consents"` +} + +// consentRecord is the (subset of the) consent receipt the plugin decides on. +type consentRecord struct { + Status string `json:"status"` + Data []struct { + Resource string `json:"resource"` + } `json:"data"` + // Consumer / DataConsumer are the two field names the consent-manager has + // used for the participant the data is released to; either may be present. + Consumer participantRef `json:"consumer"` + DataConsumer participantRef `json:"dataConsumer"` + Purposes []struct { + ID string `json:"_id"` + Purpose string `json:"purpose"` + } `json:"purposes"` +} + +// participantRef is a participant named inside a consent record. The +// consent-manager returns it either as a bare identifier string or as an +// embedded object, so it decodes both shapes and matches on any of the +// identifiers it carries. +type participantRef struct { + ID string `json:"_id"` + DID string `json:"did"` + SelfDescriptionURL string `json:"selfDescriptionURL"` + // literal holds the value when the field was a bare string rather than an object. + literal string +} + +// UnmarshalJSON accepts either a bare identifier string or a participant object. +func (p *participantRef) UnmarshalJSON(data []byte) error { + var literal string + if err := json.Unmarshal(data, &literal); err == nil { + p.literal = literal + return nil + } + // Alias avoids recursing into this method while decoding the object form. + type participantRefObject participantRef + var obj participantRefObject + if err := json.Unmarshal(data, &obj); err != nil { + return fmt.Errorf("consent client: failed to unmarshal participant reference: %w", err) + } + *p = participantRef(obj) + return nil +} + +// matches reports whether this reference denotes the given participant identity +// (a self-description URL, but a record may name the participant by its id or +// DID instead). An empty reference matches nothing. +func (p participantRef) matches(identity string) bool { + if identity == "" { + return false + } + for _, candidate := range []string{p.SelfDescriptionURL, p.ID, p.DID, p.literal} { + if candidate != "" && candidate == identity { + return true + } + } + return false +} + +// grantedTo reports whether the consent was granted to the given consumer. +func (r consentRecord) grantedTo(consumer string) bool { + return r.Consumer.matches(consumer) || r.DataConsumer.matches(consumer) +} + +// coversPurpose reports whether the consent covers the given processing purpose. +// An empty purpose means the caller could not determine one, so the purpose is +// not part of the match. +func (r consentRecord) coversPurpose(purpose string) bool { + if purpose == "" { + return true + } + for _, p := range r.Purposes { + if p.Purpose == purpose || p.ID == purpose { + return true + } + } + return false +} + +// coversResource reports whether the consent covers the given data resource. An +// empty resource means the check is owner-level and any resource qualifies. +func (r consentRecord) coversResource(dataResource string) bool { + if dataResource == "" { + return true + } + for _, d := range r.Data { + if d.Resource == dataResource { + return true + } + } + return false } // hasGrantedConsent performs call 2: it lists the user identifier's consents as -// 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) { +// seen by the participant and reports whether any of them authorizes THIS +// access. A consent qualifies only when all of the following hold: +// +// - its status is "granted"; +// - it was granted to req.Consumer — a consent names one consumer, and one +// granted to participant X is not authority for participant Y to read the +// same data; +// - it covers req.Purpose, when the caller could determine one; +// - it covers req.DataResource, when the check is resource-scoped. +// +// A record that names no consumer therefore never qualifies: the plugin cannot +// tell whose agreement it is, and guessing would authorise a processing purpose +// the subject never agreed to. 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, req ConsentRequest) (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 { @@ -659,18 +776,13 @@ func (c *Client) hasGrantedConsent(ctx context.Context, token, userIdentifier, d if err := json.Unmarshal(body, &out); err != nil { return false, fmt.Errorf("consent client: failed to unmarshal consents response: %w", err) } - for _, consent := range out.Consents { - if consent.Status != grantedStatus { + for _, record := range out.Consents { + if record.Status != grantedStatus { continue } - if dataResource == "" { + if record.grantedTo(req.Consumer) && record.coversPurpose(req.Purpose) && record.coversResource(req.DataResource) { return true, nil } - for _, d := range consent.Data { - if d.Resource == dataResource { - return true, nil - } - } } return false, nil } diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index f2aad2b..b6300e1 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -37,6 +37,10 @@ const tokenServicePath = "/internal/tokens" // testAudience is the configured token-service target the tests ask for. const testAudience = "consent-manager" +// testConsumerSD is the self-description URL of the participant the data is +// released to — the consumer every check is scoped to. +const testConsumerSD = "http://catalog/participants/consumer" + // 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, @@ -48,6 +52,8 @@ type mockCM struct { statuses []string // consents statuses resourcesPerConsent [][]string // optional data[].resource per consent (index-aligned with statuses) selfDescriptionURL string // /me result + consentConsumer string // consumer the returned consents name (defaults to testConsumerSD) + consentPurposes []string // optional purposes the returned consents cover tokenStatus int // non-200 => the token service fails with this status failFirstConsents bool // first consents call 401s, then succeeds // recording @@ -120,6 +126,13 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { fail := m.failFirstConsents sts := append([]string(nil), m.statuses...) res := append([][]string(nil), m.resourcesPerConsent...) + consumer := m.consentConsumer + if consumer == "" { + consumer = testConsumerSD + } + // "none" makes the mock return a consent record that names no consumer. + omitConsumer := consumer == "none" + purposes := append([]string(nil), m.consentPurposes...) m.mu.Unlock() if fail && n == 1 { w.WriteHeader(http.StatusUnauthorized) @@ -128,6 +141,16 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { consents := make([]map[string]interface{}, 0, len(sts)) for i, s := range sts { consent := map[string]interface{}{"status": s} + if !omitConsumer { + consent["consumer"] = map[string]string{"selfDescriptionURL": consumer} + } + if len(purposes) > 0 { + ps := make([]map[string]string, 0, len(purposes)) + for _, p := range purposes { + ps = append(ps, map[string]string{"purpose": p}) + } + consent["purposes"] = ps + } if i < len(res) { data := make([]map[string]string, 0, len(res[i])) for _, r := range res[i] { @@ -161,7 +184,7 @@ func TestCheckConsent_HostOverride(t *testing.T) { 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 { + if _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD}); err != nil { t.Fatalf("CheckConsent: %v", err) } if m.lastHost != "consent-manager.dataspace-authority.org" { @@ -191,17 +214,17 @@ func TestCheckConsent_ResourceScoped(t *testing.T) { 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"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD, 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"}) + resp, err = c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD, 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"}) + resp, err = c.CheckConsent(context.Background(), ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) } @@ -229,25 +252,43 @@ func TestCheckConsent(t *testing.T) { uid = "6a71e3567917ddaef2e2c866" ) tests := []struct { - name string - userID string - statuses []string - wantDecision Decision + name string + userID string + statuses []string + consentConsumer string // consumer the mock's consents were granted to + requestConsumer string // consumer the check is made for + wantDecision Decision }{ {name: "granted -> allow", userID: uid, statuses: []string{"granted"}, wantDecision: DecisionAllow}, {name: "one of many granted -> allow", userID: uid, statuses: []string{"revoked", "granted"}, wantDecision: DecisionAllow}, {name: "only revoked -> deny", userID: uid, statuses: []string{"revoked"}, wantDecision: DecisionDeny}, {name: "no consents -> deny", userID: uid, statuses: []string{}, wantDecision: DecisionDeny}, {name: "unknown subject (404) -> deny", userID: "", statuses: nil, wantDecision: DecisionDeny}, + { + name: "granted to another consumer -> deny", userID: uid, statuses: []string{"granted"}, + consentConsumer: "http://catalog/participants/someone-else", wantDecision: DecisionDeny, + }, + { + name: "consent naming no consumer -> deny", userID: uid, statuses: []string{"granted"}, + consentConsumer: "none", wantDecision: DecisionDeny, + }, + { + name: "no consumer in the request -> deny", userID: uid, statuses: []string{"granted"}, + requestConsumer: "none", wantDecision: DecisionDeny, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resetCredCache() - m := &mockCM{userID: tt.userID, statuses: tt.statuses} + m := &mockCM{userID: tt.userID, statuses: tt.statuses, consentConsumer: tt.consentConsumer} srv := newMockCM(t, m) c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static-token", ProviderSD: provSD}) - resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: subject}) + requestConsumer := testConsumerSD + if tt.requestConsumer == "none" { + requestConsumer = "" + } + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: subject, Consumer: requestConsumer}) require.NoError(t, err) assert.Equal(t, tt.wantDecision, resp.Decision) @@ -255,6 +296,10 @@ func TestCheckConsent(t *testing.T) { defer m.mu.Unlock() 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") + if tt.requestConsumer == "none" { + assert.Equal(t, 0, m.searchCalls, "a check without a consumer must not reach the consent-manager") + return + } assert.Equal(t, "ck", m.lastConsentKey) assert.Equal(t, provSD, m.lastSearchSD) assert.Equal(t, subject, m.lastSearchEmail) @@ -273,7 +318,7 @@ func TestCheckConsent_TokenService(t *testing.T) { srv := newMockCM(t, m) 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"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) @@ -295,7 +340,7 @@ func TestCheckConsent_TokenAndSDCached(t *testing.T) { 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"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) } @@ -315,7 +360,7 @@ func TestCheckConsent_401RefreshRetry(t *testing.T) { srv := newMockCM(t, m) 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"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) @@ -333,7 +378,7 @@ func TestCheckConsent_TokenServiceFailure(t *testing.T) { srv := newMockCM(t, m) c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) - _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.Error(t, err) assert.Contains(t, err.Error(), "token service returned status 404") } @@ -346,7 +391,7 @@ func TestCheckConsent_ProviderSDOverride(t *testing.T) { srv := newMockCM(t, m) 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"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) @@ -364,7 +409,7 @@ func TestCheckConsent_EmptySubject(t *testing.T) { srv := newMockCM(t, m) c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) - resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: ""}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionDeny, resp.Decision) m.mu.Lock() @@ -378,7 +423,7 @@ func TestCheckConsent_EmptySubject(t *testing.T) { 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"}) + _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.Error(t, err) assert.Contains(t, err.Error(), "no participant_token and no token_service_url") } @@ -391,7 +436,7 @@ func TestCheckConsent_EmptyConsentKeyOmitsHeader(t *testing.T) { m := &mockCM{userID: "uid-1", statuses: []string{"granted"}} srv := newMockCM(t, m) c := NewClient(ClientConfig{BaseURL: srv.URL, ParticipantToken: "t", ProviderSD: "sd"}) - resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) assert.Equal(t, DecisionAllow, resp.Decision) assert.Empty(t, m.lastConsentKey, "empty consent key must not be sent as a header") @@ -414,7 +459,7 @@ func TestCheckConsent_ConcurrentTokenFetchCoalesced(t *testing.T) { defer wg.Done() // Same base URL + client id => same cache key, so the login must coalesce. c := NewClient(ClientConfig{BaseURL: srv.URL, TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) - resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) if err != nil { errs <- err } else if resp.Decision != DecisionAllow { @@ -438,7 +483,7 @@ func TestCheckConsent_ConcurrentTokenFetchCoalesced(t *testing.T) { func TestCheckConsentTransportFailure(t *testing.T) { resetCredCache() c := NewClient(ClientConfig{BaseURL: "http://localhost:1", ConsentKey: "ck", ParticipantToken: "t", ProviderSD: "sd", TimeoutMs: MinTimeoutMs}) - _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.Error(t, err) assert.Contains(t, err.Error(), "HTTP request failed") } @@ -453,7 +498,7 @@ func TestCheckConsentContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "t", ProviderSD: "sd"}) - _, err := c.CheckConsent(ctx, ConsentRequest{Subject: "did:key:z"}) + _, err := c.CheckConsent(ctx, ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.Error(t, err) assert.Contains(t, err.Error(), "HTTP request failed") } @@ -564,7 +609,9 @@ func TestCheckConsent_SeparateTokenServicesDoNotShareToken(t *testing.T) { mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "consents": []map[string]string{{"status": grantedStatus}}, + "consents": []map[string]interface{}{ + {"status": grantedStatus, "consumer": map[string]string{"selfDescriptionURL": testConsumerSD}}, + }, }) }) consentManager := httptest.NewServer(mux) @@ -590,7 +637,7 @@ func TestCheckConsent_SeparateTokenServicesDoNotShareToken(t *testing.T) { TokenServiceURL: newTokenService(token).URL, TokenAudience: testAudience, }) - resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z"}) + resp, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) require.NoError(t, err) require.Equal(t, DecisionAllow, resp.Decision) } diff --git a/internal/consent/models.go b/internal/consent/models.go index 9d7dc28..6926da3 100644 --- a/internal/consent/models.go +++ b/internal/consent/models.go @@ -70,6 +70,19 @@ type ConsentRequest struct { // Empty means owner-level (any granted consent counts). DataResource string `json:"data_resource,omitempty"` + // Consumer identifies the participant the data is being released TO, as its + // self-description URL. It is REQUIRED: a consent is an agreement between a + // data subject and one named consumer for one named purpose, so a check that + // ignores it would let participant Y ride on a consent the subject granted to + // participant X. A check without a consumer is denied. + Consumer string `json:"consumer,omitempty"` + + // Purpose, when set, further scopes the check to the processing purpose (or + // contract) the exchange is governed by: a granted consent counts only if it + // covers this purpose. Empty means the purpose is not known — the consumer + // match still applies. + Purpose string `json:"purpose,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 c887b48..492b33b 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -196,12 +196,8 @@ func newConsentManager(t *testing.T, wantSubject, userID string, statuses []stri mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer itest-participant-token", r.Header.Get("Authorization"), "consents lookup must carry the participant token") - consents := make([]map[string]string, 0, len(statuses)) - for _, s := range statuses { - consents = append(consents, map[string]string{"status": s}) - } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consentsGrantedTo(statuses)}) }) mux.HandleFunc("/v1/participants", participantRegistryHandler) @@ -214,14 +210,44 @@ func newConsentManager(t *testing.T, wantSubject, userID string, statuses []stri // itestConsumerDID is the consuming participant named in the access token. const itestConsumerDID = "did:key:zConsumer" +// itestConsumerSD is the self-description URL the participant registry maps +// itestConsumerDID to — the consumer every consent check is scoped to. +const itestConsumerSD = "http://catalog/participants/consumer" + +// consentsGrantedTo builds consent records with the given statuses, each granted +// to the consuming participant the tests act as. +func consentsGrantedTo(statuses []string) []map[string]interface{} { + consents := make([]map[string]interface{}, 0, len(statuses)) + for _, s := range statuses { + consents = append(consents, map[string]interface{}{ + "status": s, + "consumer": map[string]string{"selfDescriptionURL": itestConsumerSD}, + }) + } + return consents +} + // participantRegistryHandler serves the consent-manager's participant registry, // which translates the consumer DID from the token into the self-description URL // a contract names its parties by. func participantRegistryHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]map[string]string{ - {"did": itestConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, + {"did": itestConsumerDID, "selfDescriptionURL": itestConsumerSD}, + }) +} + +// newFailingConsentManager returns a consent-manager whose CONSENT CHECK calls +// answer with the given status code (used to exercise the fail policy). The +// participant registry still answers, so the failure under test is the check +// itself and not the preceding contract lookup. +func newFailingConsentManager(status int) *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", participantRegistryHandler) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) }) + return httptest.NewServer(mux) } // newOwnerResolver starts a mock OwnerResolver that reports the given data @@ -394,9 +420,7 @@ func TestIntegration_CustomDenyResponse(t *testing.T) { // TestIntegration_ConsentManagerError_FailOpen verifies a consent-manager error // passes through when fail-open is set. func TestIntegration_ConsentManagerError_FailOpen(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) + srv := newFailingConsentManager(http.StatusInternalServerError) defer srv.Close() resolver := newOwnerResolver(t, "did:key:zAlice") @@ -415,9 +439,7 @@ func TestIntegration_ConsentManagerError_FailOpen(t *testing.T) { // TestIntegration_ConsentManagerError_FailClosed verifies a consent-manager error // is denied when fail-closed. func TestIntegration_ConsentManagerError_FailClosed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) + srv := newFailingConsentManager(http.StatusServiceUnavailable) defer srv.Close() resolver := newOwnerResolver(t, "did:key:zAlice") @@ -530,12 +552,8 @@ func newConsentManagerTokenService(t *testing.T, wantSubject, userID, selfDescri mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer itest-token", r.Header.Get("Authorization")) - consents := make([]map[string]string, 0, len(statuses)) - for _, s := range statuses { - consents = append(consents, map[string]string{"status": s}) - } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consentsGrantedTo(statuses)}) }) mux.HandleFunc("/v1/participants", participantRegistryHandler) diff --git a/internal/ownerresolver/client.go b/internal/ownerresolver/client.go index 5298092..4a38b54 100644 --- a/internal/ownerresolver/client.go +++ b/internal/ownerresolver/client.go @@ -56,6 +56,11 @@ type Claim struct { OwnerID string `json:"ownerId"` Participant string `json:"participant,omitempty"` DataResource string `json:"dataResource,omitempty"` + // Purpose names the processing purpose (or contract) governing this claim, + // when the resolver could identify the contract from the parties. It scopes + // the consent match: a granted consent counts only if it covers this purpose. + // Empty means the purpose is unknown and only the consumer match applies. + Purpose string `json:"purpose,omitempty"` } // Result is the OwnerResolver response. diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 6f846cd..f21b54c 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -254,6 +254,10 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke resolveParties.Consumer = consumerSD } } + // The consumer also scopes the consent match itself: a consent names the one + // participant it was granted to, so releasing data to any other participant + // on the strength of it would authorise an agreement the subject never made. + consumerSD := resolveParties.Consumer 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 { @@ -296,6 +300,8 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke Resource: reqCtx.Path, Method: reqCtx.Method, DataResource: claim.DataResource, + Consumer: consumerSD, + Purpose: claim.Purpose, } resp, err := consentClient.CheckConsent(context.Background(), req) if err != nil { diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 844a92d..79acd3a 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -166,21 +166,12 @@ func newConsentManager(t *testing.T, userID string, statuses []string) *httptest _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": userID}) }) mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, r *http.Request) { - consents := make([]map[string]string, 0, len(statuses)) - for _, s := range statuses { - consents = append(consents, map[string]string{"status": s}) - } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consents}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consentsGrantedTo(testConsumerSD, statuses)}) }) // The participant registry, used to translate the consumer DID from the token // into the self-description URL a contract names its parties by. - mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]map[string]string{ - {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, - }) - }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) mux.HandleFunc("/v1/participants/me", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ @@ -190,12 +181,26 @@ func newConsentManager(t *testing.T, userID string, statuses []string) *httptest return httptest.NewServer(mux) } -// newFailingConsentManager returns a consent-manager that answers every call -// with the given status code (used to exercise the fail policy). +// newFailingConsentManager returns a consent-manager whose CONSENT CHECK calls +// answer with the given status code (used to exercise the fail policy). The +// participant registry still answers, so the failure under test is the check +// itself and not the preceding contract lookup. func newFailingConsentManager(status int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", participantRegistryHandler) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) - })) + }) + return httptest.NewServer(mux) +} + +// participantRegistryHandler serves the consent-manager's participant registry, +// which maps the consumer DID from the token to its self-description URL. +func participantRegistryHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"did": testConsumerDID, "selfDescriptionURL": testConsumerSD}, + }) } // newUncalledConsentManager fails the test if a CONSENT CHECK reaches the @@ -205,12 +210,7 @@ func newFailingConsentManager(status int) *httptest.Server { func newUncalledConsentManager(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]map[string]string{ - {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, - }) - }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) mux.HandleFunc("/", func(_ http.ResponseWriter, r *http.Request) { t.Errorf("consent-manager must not be called for a consent check (path %s)", r.URL.Path) }) @@ -308,6 +308,23 @@ const testOwnerDID = "did:key:zOwner" // testConsumerDID is the requesting participant named in the token claims. const testConsumerDID = "did:key:zConsumer" +// testConsumerSD is the self-description URL the participant registry maps +// testConsumerDID to — the consumer every consent check is scoped to. +const testConsumerSD = "http://catalog/participants/consumer" + +// consentsGrantedTo builds consent records with the given statuses, each granted +// to the named consuming participant. +func consentsGrantedTo(consumer string, statuses []string) []map[string]interface{} { + consents := make([]map[string]interface{}, 0, len(statuses)) + for _, s := range statuses { + consents = append(consents, map[string]interface{}{ + "status": s, + "consumer": map[string]string{"selfDescriptionURL": consumer}, + }) + } + return consents +} + // --- ResponseFilter tests (coarse allow/deny gate) --- func TestConsentFilter_ResponseFilter(t *testing.T) { @@ -537,6 +554,40 @@ func TestConsentFilter_ResponseFilter_DenySetsContentType(t *testing.T) { assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus) } +// TestResponseFilter_ConsentScopedToConsumer is the regression test for the +// consumer scoping: the owner's consent was granted to a DIFFERENT participant, +// so it is no authority for this consumer to read the data. +func TestResponseFilter_ConsentScopedToConsumer(t *testing.T) { + clearContextStore() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-owner"}) + }) + mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "consents": consentsGrantedTo("http://catalog/participants/someone-else", []string{"granted"}), + }) + }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) + server := httptest.NewServer(mux) + defer server.Close() + + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() + + const id = uint32(203) + storeRequest(id) + resp := newMockResponse(id, []byte(`{"id":"urn:ngsi-ld:PersonalProfile:alice"}`)) + (&ConsentFilter{}).ResponseFilter(newTestConfig(server.URL, resolver.URL+"/resolve"), resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "a consent granted to another participant must not authorise this consumer") + assert.Equal(t, DefaultDenyResponseBody, string(resp.writtenBody)) +} + // TestResponseFilter_OwnerNotRequestor is the regression test for the removed // legacy mode: the consent that decides access must be the RESOLVED OWNER's, not // the caller's. The resolver names Bob as the owner while the token's "sub" is @@ -557,16 +608,9 @@ func TestResponseFilter_OwnerNotRequestor(t *testing.T) { }) mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "consents": []map[string]string{{"status": "revoked"}}, - }) - }) - mux.HandleFunc("/v1/participants", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]map[string]string{ - {"did": testConsumerDID, "selfDescriptionURL": "http://catalog/participants/consumer"}, - }) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consentsGrantedTo(testConsumerSD, []string{"revoked"})}) }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) server := httptest.NewServer(mux) defer server.Close() @@ -576,9 +620,12 @@ func TestResponseFilter_OwnerNotRequestor(t *testing.T) { const id = uint32(202) // The caller is Alice; the data belongs to Bob. StoreRequestContext(testReqKey(id), &RequestContext{ - Method: "GET", - Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob", - JWTClaims: map[string]interface{}{"sub": "did:key:zAlice"}, + Method: "GET", + Path: "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob", + JWTClaims: map[string]interface{}{ + "sub": "did:key:zAlice", + "verifiableCredential": map[string]interface{}{"issuer": testConsumerDID}, + }, }) resp := newMockResponse(id, []byte(`{"id":"urn:ngsi-ld:PersonalProfile:bob"}`)) From 4728098ce03428c5855aa109f8dbfa3db85e2e3f Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:44:39 +0200 Subject: [PATCH 04/41] fix(plugin): fail closed when the exchange parties cannot be resolved (H-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both party lookups in evaluateWithResolver logged their error and carried on: if consumerSD, sdErr := ...; sdErr != nil { log.Printf(...) } else { ... } if providerSD, sdErr := ...; sdErr != nil { log.Printf(...) } else { ... } Both failing left `resolveParties` empty, `Parties.IsZero()` then omitted the field from the /resolve request entirely, and the plugin went on to trust whatever came back — including `consentRequired: false`, which is an unconditional allow. A fail-closed design with a fail-open seam in the middle of it, reachable by nothing more dramatic than a briefly unreachable consent-manager or a revoked participant token. Both lookups now run before the resolver is asked anything, and either failing is terminal — the fail policy applies instead of an unidentified-party /resolve result reaching the allow branch. A token that names no consuming participant at all is treated the same way. No degraded mode is offered: with the consumer now scoping the consent match itself (C-2), a check without one could not be sound anyway. `ParticipantSelfDescriptionByDID` also gains what only `CheckConsent` had: - a 401 refresh-and-retry, so a cached token that has since been revoked does not make the party mapping — and with it the whole exchange — fail terminally; - negative caching of "no such participant" (30s), so one misconfigured DID no longer re-fetches the entire participant list on every request; - a cache key that includes the full credential identity rather than just the base URL, matching the C-3 fix. Tests: the 401 retry and the negative caching in the consent package, and two plugin cases asserting an unresolvable consumer and a token with no consumer claim both deny without the resolver being contacted at all. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 57 +++++++++++++++++++--- internal/consent/client_test.go | 84 +++++++++++++++++++++++++++++++++ internal/plugin/consent.go | 39 ++++++++++----- internal/plugin/consent_test.go | 30 ++++++++++++ 4 files changed, 190 insertions(+), 20 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index 21330c6..888c5b4 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -371,9 +371,18 @@ func (c *Client) credentials(ctx context.Context, forceFetch bool) (token, provi // off the request path. const participantSDCacheTTL = 10 * time.Minute +// participantSDNegativeTTL bounds how long a "no such participant" answer is +// reused. Without it a single misconfigured DID re-fetches the whole participant +// list on every request; with it, a participant that is genuinely registered +// later is still picked up promptly. +const participantSDNegativeTTL = 30 * time.Second + type participantSDEntry struct { selfDescriptionURL string expiry time.Time + // unknown marks a negative result: the registry answered, and no participant + // with this DID was in it. + unknown bool } var ( @@ -396,21 +405,58 @@ type participantsResponse struct { // 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. +// participantSDCacheTTL, and "no such participant" for participantSDNegativeTTL, +// so a misconfigured DID does not re-fetch the registry on every request. 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 + cacheKey := c.cacheKey() + credentialKeySeparator + did participantSDMu.Lock() entry, hit := participantSDCache[cacheKey] participantSDMu.Unlock() if hit && time.Now().Before(entry.expiry) { + if entry.unknown { + return "", fmt.Errorf("consent client: no participant registered for did %q", did) + } return entry.selfDescriptionURL, nil } - token, _, err := c.credentials(ctx, false) + // A cached token that has since been revoked would otherwise make the mapping + // terminally fail, and with it the whole exchange — so refresh and retry once, + // exactly as CheckConsent does for the check itself. + sd, err := c.lookupParticipantSD(ctx, did, false) + if errors.Is(err, errParticipantUnauthorized) && c.staticToken == "" { + sd, err = c.lookupParticipantSD(ctx, did, true) + } + if errors.Is(err, errParticipantUnauthorized) { + return "", fmt.Errorf("consent client: participant token rejected (401) on participants lookup") + } + if err != nil { + return "", err + } + + participantSDMu.Lock() + if sd == "" { + participantSDCache[cacheKey] = participantSDEntry{unknown: true, expiry: time.Now().Add(participantSDNegativeTTL)} + } else { + participantSDCache[cacheKey] = participantSDEntry{selfDescriptionURL: sd, expiry: time.Now().Add(participantSDCacheTTL)} + } + participantSDMu.Unlock() + + if sd == "" { + return "", fmt.Errorf("consent client: no participant registered for did %q", did) + } + return sd, nil +} + +// lookupParticipantSD fetches the participant registry and returns the +// self-description URL registered for did, or "" when the registry answered but +// holds no such participant (a definite negative, not an error). forceLogin +// refreshes a cached token first. +func (c *Client) lookupParticipantSD(ctx context.Context, did string, forceLogin bool) (string, error) { + token, _, err := c.credentials(ctx, forceLogin) if err != nil { return "", err } @@ -437,13 +483,10 @@ func (c *Client) ParticipantSelfDescriptionByDID(ctx context.Context, did string } 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) + return "", nil } // decodeParticipants accepts either a bare array or a {"participants": [...]} diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index b6300e1..8198c94 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -174,6 +174,15 @@ func resetCredCache() { credCacheMu.Lock() credCache = map[string]*cacheEntry{} credCacheMu.Unlock() + resetParticipantSDCache() +} + +// resetParticipantSDCache clears the package-wide DID -> self-description cache +// between tests, so a positive or negative result cannot leak across them. +func resetParticipantSDCache() { + participantSDMu.Lock() + participantSDCache = map[string]participantSDEntry{} + participantSDMu.Unlock() } // TestCheckConsent_HostOverride verifies the configured Host header is sent to @@ -647,3 +656,78 @@ func TestCheckConsent_SeparateTokenServicesDoNotShareToken(t *testing.T) { assert.Equal(t, []string{"Bearer token-participant-a", "Bearer token-participant-b"}, consentsAuth, "each participant must authenticate with its own token, not the first one cached") } + +// --- Participant registry lookup (the contract-side party mapping) --- + +// newParticipantRegistry starts a mock consent-manager participant registry that +// counts its calls and can 401 the first one. +func newParticipantRegistry(t *testing.T, entries []map[string]string, calls *int, fail401First *bool) *httptest.Server { + t.Helper() + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + *calls++ + n := *calls + mu.Unlock() + if fail401First != nil && *fail401First && n == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entries) + })) + t.Cleanup(srv.Close) + return srv +} + +// TestParticipantSelfDescriptionByDID_401RefreshRetry verifies a rejected cached +// token is refreshed and the registry lookup retried, rather than failing +// terminally — a stale token must not take the whole exchange down. +func TestParticipantSelfDescriptionByDID_401RefreshRetry(t *testing.T) { + resetCredCache() + + var registryCalls int + fail := true + registry := newParticipantRegistry(t, + []map[string]string{{"did": "did:key:zConsumer", "selfDescriptionURL": testConsumerSD}}, + ®istryCalls, &fail) + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "tok", "token_type": "Bearer", "expires_in": 3600, + }) + })) + t.Cleanup(tokenSrv.Close) + + c := NewClient(ClientConfig{ + BaseURL: registry.URL, APIPrefix: "", ProviderSD: "sd", + TokenServiceURL: tokenSrv.URL, TokenAudience: testAudience, + }) + + sd, err := c.ParticipantSelfDescriptionByDID(context.Background(), "did:key:zConsumer") + require.NoError(t, err) + assert.Equal(t, testConsumerSD, sd) + assert.Equal(t, 2, registryCalls, "the 401 must trigger one refresh and retry") +} + +// TestParticipantSelfDescriptionByDID_NegativeCaching verifies an unknown DID is +// remembered as unknown, so a misconfiguration does not re-fetch the whole +// participant list on every request. +func TestParticipantSelfDescriptionByDID_NegativeCaching(t *testing.T) { + resetCredCache() + + var registryCalls int + registry := newParticipantRegistry(t, []map[string]string{}, ®istryCalls, nil) + + c := NewClient(ClientConfig{ + BaseURL: registry.URL, APIPrefix: "", ParticipantToken: "static", ProviderSD: "sd", + }) + + for i := 0; i < 3; i++ { + _, err := c.ParticipantSelfDescriptionByDID(context.Background(), "did:key:zUnregistered") + require.Error(t, err) + assert.Contains(t, err.Error(), "no participant registered") + } + assert.Equal(t, 1, registryCalls, "an unknown DID must be remembered, not re-fetched per request") +} diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index f21b54c..cee6053 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -242,27 +242,40 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke 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. + // + // Both sides are resolved BEFORE the resolver is asked anything, and a failure + // on either is terminal. Proceeding with an empty Parties would omit the field + // from /resolve entirely, and a resolver that cannot identify a contract may + // answer consentRequired:false — which is an unconditional allow. That would + // put a fail-open seam in the middle of a fail-closed design, reachable by + // nothing more than a briefly unreachable consent-manager or a revoked token. 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 - } + consumerDID := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim) + if consumerDID == "" { + log.Printf("[consent-filter] ResponseFilter: no consuming participant in the token claims (path %q) for request %s", cfg.ConsumerClaim, key) + return failOutcome(cfg, "no consuming participant identified", key, nil) + } + consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(context.Background(), consumerDID) + if sdErr != nil { + log.Printf("[consent-filter] ResponseFilter: could not map the consumer to a participant for request %s: %v", key, sdErr) + return failOutcome(cfg, "consumer participant lookup failed: "+sdErr.Error(), key, nil) } // The consumer also scopes the consent match itself: a consent names the one // participant it was granted to, so releasing data to any other participant // on the strength of it would authorise an agreement the subject never made. - consumerSD := resolveParties.Consumer - if providerSD, sdErr := consentClient.ProviderSelfDescription(context.Background()); sdErr != nil { + resolveParties.Consumer = consumerSD + + providerSD, sdErr := consentClient.ProviderSelfDescription(context.Background()) + if sdErr != nil { log.Printf("[consent-filter] ResponseFilter: could not determine the provider self-description for request %s: %v", key, sdErr) - } else { - resolveParties.Provider = providerSD + return failOutcome(cfg, "provider self-description lookup failed: "+sdErr.Error(), key, nil) } + resolveParties.Provider = providerSD + + resolverClient := ownerresolver.NewClient(cfg.OwnerResolverURL, cfg.OwnerResolverTimeout) result, err := resolverClient.Resolve(context.Background(), ownerresolver.Resource{ Service: cfg.Service, Method: reqCtx.Method, @@ -420,8 +433,8 @@ 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. +// does not resolve to a string, which the caller treats as a failure to identify +// the exchange - the fail policy then applies. func consumerFromClaims(claims map[string]interface{}, path string) string { if len(claims) == 0 || path == "" { return "" diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 79acd3a..b7ff1b1 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -461,6 +461,36 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, + { + name: "unresolvable consumer denies without asking the resolver", + setupContext: func(id uint32) { + StoreRequestContext(testReqKey(id), &RequestContext{ + Method: "GET", Path: "/data", + JWTClaims: map[string]interface{}{ + "verifiableCredential": map[string]interface{}{"issuer": "did:key:zNotRegistered"}, + }, + }) + }, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, + { + name: "no consumer claim in the token denies without asking the resolver", + setupContext: func(id uint32) { + StoreRequestContext(testReqKey(id), &RequestContext{ + Method: "GET", Path: "/data", + JWTClaims: map[string]interface{}{"sub": "did:key:zCaller"}, + }) + }, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, { name: "invalid config type passes through", setupContext: storeRequest, From 7e988b1514f87ab651310a7344aa81fc31002f68 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:46:16 +0200 Subject: [PATCH 05/41] fix(plugin)!: default to fail-closed and never fail open on misconfiguration (H-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fail_open` defaulted to true, so every unresolved situation — consent-manager down, resolver erroring, missing request context, missing credentials, unreadable body — became ALLOW unless the operator explicitly opted out. For an availability filter that default is defensible; for a consent gate on personal data it inverts the safe default, and it is reached by omission. Two changes: 1. `IsFailOpen()` now returns false when unset. `fail_open: true` remains available as a deliberate opt-out, and `ParseConfig` logs a warning when it is enabled so the choice is visible in the runner's output. 2. Not every unresolved situation is an outage, so `failOutcome` now takes a `failMode`. `failByPolicy` (resolver/consent-manager/body-read failures) is what `fail_open` governs. `failAlwaysClosed` denies regardless: no correlation id, no request context, no participant credentials at all, and "consent required but no owner resolved". None of those are transient conditions to ride out — failing them open turns a lost request or a typo in the route config into a silent, total bypass of the gate, which is exactly how this finding composes with the documentation drift. Missing credentials are recognised through a new `consent.ErrNoCredentials` sentinel rather than by string matching. BREAKING CHANGE: routes that relied on the implicit fail-open default now deny on a dependency failure. Set `"fail_open": true` explicitly to keep the old behaviour. Tests: each fail-open case is split into a default (deny) and an explicit opt-in (pass through) case, plus two cases proving that a missing request context and missing participant credentials deny even with fail_open enabled. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 8 ++++- internal/plugin/config.go | 22 +++++++++---- internal/plugin/consent.go | 58 +++++++++++++++++++++++++-------- internal/plugin/consent_test.go | 53 +++++++++++++++++++++++++----- 4 files changed, 111 insertions(+), 30 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index 888c5b4..672c145 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -84,6 +84,12 @@ const ( // (HTTP 401), so a cached token should be refreshed and the call retried. var errParticipantUnauthorized = errors.New("consent client: participant token unauthorized") +// ErrNoCredentials signals that the client has no way to authenticate as the +// participant at all. It is a misconfiguration, not an availability failure: +// callers must never treat it as a transient error to be failed open on, or a +// mistyped route config becomes a silent, total bypass of the gate. +var ErrNoCredentials = errors.New("consent client: no participant_token and no token_service_url configured") + // ClientConfig holds everything needed to verify consent against the // (Prometheus-X / Visions) consent-manager. type ClientConfig struct { @@ -315,7 +321,7 @@ func (c *Client) credentials(ctx context.Context, forceFetch bool) (token, provi return c.staticToken, c.providerSD, nil } if c.staticToken == "" && c.tokenServiceURL == "" { - return "", "", fmt.Errorf("consent client: no participant_token and no token_service_url configured") + return "", "", ErrNoCredentials } // Get-or-create the per-key entry under the map lock (brief), then release it diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 3bc9f7c..f500240 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/url" "os" ) @@ -205,10 +206,12 @@ type Config struct { // Defaults to DefaultDenyResponseContentType ("application/json"). DenyResponseContentType string `json:"deny_response_content_type,omitempty"` - // FailOpen controls the behavior when the consent API is unavailable or - // returns an error. When nil or true (default), responses pass through - // on consent API errors (fail-open). When false, responses are denied - // on consent API errors (fail-closed). + // FailOpen controls what happens when a dependency is unavailable or errors. + // It defaults to FALSE (fail-closed): the failure mode of a consent gate must + // not be "release the personal data", and it must certainly not be reached by + // omitting a field. Setting it to true is a deliberate, logged decision to + // prefer availability over the gate, and even then it does not apply to + // conditions that are misconfigurations rather than outages (see failMode). FailOpen *bool `json:"fail_open,omitempty"` // AuditEnabled turns on emitting an access-decision audit event to an @@ -228,11 +231,11 @@ type Config struct { AuditServiceName string `json:"audit_service_name,omitempty"` } -// IsFailOpen returns whether the plugin should fail-open when the consent API -// is unavailable. Returns true (fail-open) by default when FailOpen is nil. +// IsFailOpen returns whether the plugin should fail-open when a dependency is +// unavailable. Returns false (fail-closed) by default when FailOpen is nil. func (c *Config) IsFailOpen() bool { if c.FailOpen == nil { - return true + return false } return *c.FailOpen } @@ -361,5 +364,10 @@ func ParseConfig(in []byte) (*Config, error) { return nil, err } + if conf.IsFailOpen() { + log.Printf("[consent-filter] WARNING: fail_open is enabled for %s — a consent-manager or resolver outage will RELEASE personal data instead of denying it", + conf.ConsentAPIURL) + } + return &conf, nil } diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index cee6053..b093ca5 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -25,6 +25,7 @@ import ( "consent-plugin/internal/jwt" "consent-plugin/internal/ownerresolver" "context" + "errors" "log" "net/http" "strings" @@ -207,7 +208,7 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom key, ok := correlationKey(w) if !ok { log.Printf("[consent-filter] ResponseFilter: could not read %q for request %d; cannot verify consent", nginxRequestIDVar, w.ID()) - return failOutcome(cfg, "no request correlation id", "", nil) + return failOutcome(cfg, failAlwaysClosed, "no request correlation id", "", nil) } // Load and delete stored request context (cleanup to prevent memory leaks). @@ -217,7 +218,7 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom // consent decision cannot be made, so honor the fail policy instead // of silently passing the response through. log.Printf("[consent-filter] ResponseFilter: no request context found for request %s; cannot verify consent", key) - return failOutcome(cfg, "no request context", key, nil) + return failOutcome(cfg, failAlwaysClosed, "no request context", key, nil) } // Resolve the data owner(s) from the response DATA and check consent per @@ -234,7 +235,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke body, err := w.ReadBody() if err != nil { 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) + return failOutcome(cfg, failByPolicy, "read upstream body: "+err.Error(), key, nil) } contentType := "" @@ -256,12 +257,12 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke consumerDID := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim) if consumerDID == "" { log.Printf("[consent-filter] ResponseFilter: no consuming participant in the token claims (path %q) for request %s", cfg.ConsumerClaim, key) - return failOutcome(cfg, "no consuming participant identified", key, nil) + return failOutcome(cfg, failAlwaysClosed, "no consuming participant identified", key, nil) } consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(context.Background(), consumerDID) if sdErr != nil { log.Printf("[consent-filter] ResponseFilter: could not map the consumer to a participant for request %s: %v", key, sdErr) - return failOutcome(cfg, "consumer participant lookup failed: "+sdErr.Error(), key, nil) + return failOutcome(cfg, failModeForError(sdErr), "consumer participant lookup failed: "+sdErr.Error(), key, nil) } // The consumer also scopes the consent match itself: a consent names the one // participant it was granted to, so releasing data to any other participant @@ -271,7 +272,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke providerSD, sdErr := consentClient.ProviderSelfDescription(context.Background()) if sdErr != nil { log.Printf("[consent-filter] ResponseFilter: could not determine the provider self-description for request %s: %v", key, sdErr) - return failOutcome(cfg, "provider self-description lookup failed: "+sdErr.Error(), key, nil) + return failOutcome(cfg, failModeForError(sdErr), "provider self-description lookup failed: "+sdErr.Error(), key, nil) } resolveParties.Provider = providerSD @@ -284,7 +285,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke }, 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) + return failOutcome(cfg, failByPolicy, "owner resolver error: "+err.Error(), key, nil) } if !result.ConsentRequired { @@ -292,7 +293,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke } 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) + return failOutcome(cfg, failAlwaysClosed, "consent required but no data owner resolved", key, nil) } // deny_all: every distinct (owner, dataResource) claim must be granted. @@ -300,7 +301,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke checked := make(map[pair]bool) for _, claim := range result.Claims { if claim.OwnerID == "" { - return failOutcome(cfg, "resolved claim without a data owner", key, nil) + return failOutcome(cfg, failAlwaysClosed, "resolved claim without a data owner", key, nil) } p := pair{owner: claim.OwnerID, resource: claim.DataResource} if checked[p] { @@ -319,7 +320,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke 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) + return failOutcome(cfg, failModeForError(err), "consent check error: "+err.Error(), key, &req) } if resp.Decision != consent.DecisionAllow { return responseOutcome{ @@ -359,12 +360,31 @@ func resourceOrPath(dataResource, path string) string { 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 +// failMode classifies why a consent decision could not be reached, because not +// every unresolved situation deserves the same policy. +type failMode int + +const ( + // failByPolicy is an availability failure of a dependency — the resolver or + // the consent-manager is down, slow, or erroring. Whether that releases the + // data is the operator's call, so cfg.FailOpen decides. + failByPolicy failMode = iota + + // failAlwaysClosed is a situation in which the plugin is structurally unable + // to gate: it cannot correlate the two phases, it never captured the request, + // it has no credentials at all, or the resolver says consent is required but + // names no owner. None of these are outages to ride out — fail_open must not + // turn a misconfiguration or a lost request into a silent bypass, so these + // always deny. + failAlwaysClosed +) + +// failOutcome builds the outcome for an unresolved consent check. mode decides +// whether the operator's fail policy applies at all. req may be nil when no // request context was captured. -func failOutcome(cfg *Config, reason, requestID string, req *consent.ConsentRequest) responseOutcome { +func failOutcome(cfg *Config, mode failMode, reason, requestID string, req *consent.ConsentRequest) responseOutcome { decision := decisionDeny - if cfg.IsFailOpen() { + if mode == failByPolicy && cfg.IsFailOpen() { decision = decisionAllow } o := responseOutcome{decision: decision, reason: reason, requestID: requestID} @@ -376,6 +396,16 @@ func failOutcome(cfg *Config, reason, requestID string, req *consent.ConsentRequ return o } +// failModeForError maps a dependency error to its fail mode: a missing +// credential is a misconfiguration that must never be failed open on, anything +// else is treated as an outage the operator's policy governs. +func failModeForError(err error) failMode { + if errors.Is(err, consent.ErrNoCredentials) { + return failAlwaysClosed + } + return failByPolicy +} + // recordAudit emits the decision to the audit sink when auditing is enabled. // The emit is asynchronous and best-effort, so it never affects the decision. func recordAudit(cfg *Config, outcome responseOutcome) { diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index b7ff1b1..5ff7e7d 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -414,10 +414,19 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "resolver error with fail-open passes through", + name: "resolver error denies by default (fail-closed)", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: func(t *testing.T) *httptest.Server { return newFailingOwnerResolver(http.StatusInternalServerError) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, + { + name: "resolver error with fail-open explicitly enabled passes through", setupContext: storeRequest, consentServer: newUncalledConsentManager, resolverServer: func(t *testing.T) *httptest.Server { return newFailingOwnerResolver(http.StatusInternalServerError) }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(true) }, wantNoWrite: true, }, { @@ -430,10 +439,19 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "consent-manager error with fail-open passes through", + name: "consent-manager error denies by default (fail-closed)", + setupContext: storeRequest, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, + { + name: "consent-manager error with fail-open explicitly enabled passes through", setupContext: storeRequest, consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(true) }, wantNoWrite: true, }, { @@ -446,11 +464,15 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { wantWrittenStatus: DefaultDenyStatusCode, }, { - name: "missing request context with fail-open passes through", - setupContext: nil, - consentServer: newUncalledConsentManager, - resolverServer: newUncalledOwnerResolver, - wantNoWrite: true, + // Losing the request context is not an outage to ride out: the plugin + // cannot gate at all, so fail_open must not turn it into a bypass. + name: "missing request context denies even with fail-open enabled", + setupContext: nil, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(true) }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, }, { name: "missing request context with fail-closed denies", @@ -491,6 +513,21 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, + { + // A route with no way to authenticate as the participant is a + // misconfiguration; fail_open must not make it a silent full bypass. + name: "missing participant credentials deny even with fail-open enabled", + setupContext: storeRequest, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + configFn: func(cfg *Config) { + cfg.FailOpen = boolPtr(true) + cfg.ParticipantToken = "" + cfg.TokenServiceURL = "" + }, + wantWrittenBody: DefaultDenyResponseBody, + wantWrittenStatus: DefaultDenyStatusCode, + }, { name: "invalid config type passes through", setupContext: storeRequest, @@ -673,7 +710,7 @@ func TestConfig_IsFailOpen(t *testing.T) { failOpen *bool want bool }{ - {name: "nil defaults to true (fail-open)", failOpen: nil, want: true}, + {name: "nil defaults to false (fail-closed)", failOpen: nil, want: false}, {name: "explicitly true is fail-open", failOpen: boolPtr(true), want: true}, {name: "explicitly false is fail-closed", failOpen: boolPtr(false), want: false}, } From be85548e43fc5093ba90e718672c13860ad41c39 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:48:35 +0200 Subject: [PATCH 06/41] fix(plugin): bound the request-context store and stop retaining bearer tokens (H-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store bridging the request and response phases was a package-level sync.Map written in RequestFilter and deleted only by the response phase. It had no TTL, no size cap, no eviction sweep and no gauge. Any request whose response phase never runs leaked one entry permanently: the client disconnects before the upstream answers, the upstream connect times out, an earlier APISIX plugin short-circuits after pre-req, ext-plugin-post-resp is missing from one of several routes, the runner restarts between phases. The runner is a long-lived process, so the map grew monotonically until OOM — and it was remotely drivable: open connections, send the request, abort before the response, and you have an unauthenticated memory-exhaustion primitive. Each leaked entry also held `RequestContext.Headers`, a copy of every request header including `Authorization: Bearer `. Nothing ever read it — the only consumer was `len(rc.Headers)` in String() — so the leak was a leak of credentials retained indefinitely, for no benefit at all. - `RequestContext.Headers` and its capture loop are gone. Only method, path and the decoded claims are kept, which is all the response phase uses. - Entries carry the time they were stored. A background janitor sweeps entries older than RequestContextTTL (60s) every 10s, and a load that finds an expired entry reports it as absent, so a stale context can never decide a fresh request. - MaxRequestContexts (100k) caps the store. On overflow it sweeps expired entries first and, if everything is live, evicts the oldest — a leak from an older request never refuses service to a new one. - `RequestContextStoreSize()` and `RequestContextsEvicted()` expose the leak: the size tracks in-flight gated requests and returns to zero when idle, and a climbing eviction count means requests are being lost or the store driven deliberately. - The unused exported `LoadRequestContext` / `DeleteRequestContext` are removed (L-1); tests assert cleanup through the size gauge instead. Tests cover expiry-on-load, the janitor sweep leaving live entries alone, cap enforcement evicting the oldest, and a guard that RequestContext holds no headers. Co-Authored-By: Claude Opus 5 --- internal/integration/integration_test.go | 4 +- internal/plugin/consent.go | 14 +- internal/plugin/consent_test.go | 3 +- internal/plugin/context.go | 193 ++++++++++++---- internal/plugin/context_test.go | 270 +++++++++-------------- 5 files changed, 273 insertions(+), 211 deletions(-) diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 492b33b..ef58cde 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -505,8 +505,8 @@ func TestIntegration_ContextCleanupAfterCycle(t *testing.T) { _ = runPluginCycle(t, marshalConfig(t, baseConfig(srv.URL, resolver.URL+"/resolve")), consentRequest(id, "did:key:zCaller"), []byte(`{"data":"test"}`)) - _, found := plugin.LoadRequestContext(integrationReqKey(id)) - assert.False(t, found, "request context should be deleted after the response cycle") + assert.Equal(t, 0, plugin.RequestContextStoreSize(), + "request context should be deleted after the response cycle") } // newConsentManagerCC starts a mock consent-manager exposing all four endpoints diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index b093ca5..fabe387 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -105,17 +105,13 @@ func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r } reqCtx := &RequestContext{ - Method: r.Method(), - Path: string(r.Path()), - Headers: make(http.Header), + Method: r.Method(), + Path: string(r.Path()), } - // Capture request headers from the request's Header view. - if srcHeaders := r.Header().View(); srcHeaders != nil { - for key, values := range srcHeaders { - reqCtx.Headers[key] = values - } - } + // Only the configured JWT header is read, and only the claims are kept. The + // full header set is deliberately not retained: it would put the caller's + // bearer token in a process-lifetime map that nothing ever reads. // Extract JWT token and decode claims from the configured header. jwtHeaderValue := r.Header().Get(cfg.JWTHeaderName) diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 5ff7e7d..6fb3977 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -598,8 +598,7 @@ func TestConsentFilter_ResponseFilter_ContextCleanup(t *testing.T) { resp := newMockResponse(id, []byte(`{}`)) (&ConsentFilter{}).ResponseFilter(cfg, resp) - _, found := LoadRequestContext(testReqKey(id)) - assert.False(t, found, "request context should be deleted after ResponseFilter") + assert.Equal(t, 0, RequestContextStoreSize(), "request context should be deleted after ResponseFilter") } func TestConsentFilter_ResponseFilter_DenySetsContentType(t *testing.T) { diff --git a/internal/plugin/context.go b/internal/plugin/context.go index 6efc9bb..59daccf 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -19,13 +19,20 @@ package plugin import ( "fmt" - "net/http" + "log" "sync" + "time" ) // RequestContext holds the captured request information that is needed // during response filtering. It is stored during RequestFilter and // retrieved during ResponseFilter. +// +// It deliberately holds no request headers. The only thing the response phase +// needs from the request is the method, the path and the decoded claims; keeping +// a copy of every header would mean retaining the Authorization bearer token for +// the lifetime of the entry, which is precisely the wrong thing to leak when an +// entry outlives its request. type RequestContext struct { // Method is the HTTP method of the original request (e.g., "GET", "POST"). Method string @@ -33,76 +40,188 @@ type RequestContext struct { // Path is the URI path of the original request. Path string - // Headers contains the HTTP headers from the original request. - Headers http.Header - // JWTClaims holds the decoded JWT claims extracted from the configured header. // The map keys are claim names and values are the claim values. JWTClaims map[string]interface{} } -// requestContextStore is a package-level concurrent-safe store that maps a -// stable per-request key to its captured RequestContext. This bridges the -// RequestFilter and ResponseFilter phases, which APISIX invokes as two -// separate RPC calls (ext-plugin-pre-req and ext-plugin-post-resp). +// Bounds on the request-context store. The store bridges two phases of the same +// HTTP request, so an entry is normally live for the duration of one upstream +// call. Anything still present well after that belongs to a request whose +// response phase will never run. +const ( + // RequestContextTTL is how long an entry may live before the janitor evicts + // it. It must comfortably exceed the upstream response time of a gated route; + // evicting too early only means the response phase finds no context and + // (fail-closed) denies. + RequestContextTTL = 60 * time.Second + + // requestContextSweepInterval is how often the janitor evicts expired entries. + requestContextSweepInterval = 10 * time.Second + + // MaxRequestContexts caps how many entries the store may hold. The cap is the + // backstop against an unauthenticated memory-exhaustion primitive: a client + // that opens requests and aborts before the response leaks one entry each. + MaxRequestContexts = 100_000 +) + +// storedRequestContext is one entry plus the time it was stored, which is what +// makes expiry possible. +type storedRequestContext struct { + ctx *RequestContext + storedAt time.Time +} + +// requestContextStore maps a stable per-request key to its captured +// RequestContext. This bridges the RequestFilter and ResponseFilter phases, +// which APISIX invokes as two separate RPC calls (ext-plugin-pre-req and +// ext-plugin-post-resp). // // The key MUST be stable across those two phases for the same HTTP request. // The runner's per-RPC id (Request.ID()/Response.ID()) is NOT stable between // them, so the Nginx `$request_id` variable is used instead (see // correlationKey in consent.go). -var requestContextStore sync.Map +// +// Entries are normally removed by LoadAndDeleteRequestContext in the response +// phase. That phase does not always run — the client disconnects, the upstream +// times out, an earlier plugin short-circuits the request, ext-plugin-post-resp +// is not attached to the route — and the runner is a long-lived process, so +// without a TTL and a cap the map grows monotonically until the runner is OOM +// killed. Both are enforced here. +var ( + requestContextMu sync.Mutex + requestContextStore = map[string]storedRequestContext{} + // contextsEvicted counts entries removed because they expired or because the + // store was full, i.e. requests whose response phase never ran. A number that + // climbs in production means requests are being lost, or the store is being + // driven deliberately. + contextsEvicted uint64 + janitorOnce sync.Once +) + +// startContextJanitor launches the background sweep exactly once. It is started +// lazily from the first Store so that importing the package (as tests and the +// runner registration do) never leaves a goroutine running for nothing. +func startContextJanitor() { + janitorOnce.Do(func() { + go func() { + ticker := time.NewTicker(requestContextSweepInterval) + defer ticker.Stop() + for range ticker.C { + if n := sweepRequestContexts(time.Now()); n > 0 { + log.Printf("[consent-filter] request-context store: evicted %d expired entr(ies), %d remaining", + n, RequestContextStoreSize()) + } + } + }() + }) +} // StoreRequestContext saves a RequestContext for the given request key. // It overwrites any previously stored context for the same key. +// +// The store is bounded: when it is full, expired entries are swept first and, +// failing that, the oldest entry is evicted so a new request is never refused +// service by a leak from an older one. func StoreRequestContext(requestKey string, ctx *RequestContext) { - requestContextStore.Store(requestKey, ctx) + startContextJanitor() + + requestContextMu.Lock() + defer requestContextMu.Unlock() + + now := time.Now() + if len(requestContextStore) >= MaxRequestContexts { + if _, replacing := requestContextStore[requestKey]; !replacing { + evictForSpaceLocked(now) + } + } + requestContextStore[requestKey] = storedRequestContext{ctx: ctx, storedAt: now} } -// LoadRequestContext retrieves the stored RequestContext for the given -// request key. Returns the context and true if found, or nil and false -// if no context exists for that key. -func LoadRequestContext(requestKey string) (*RequestContext, bool) { - val, ok := requestContextStore.Load(requestKey) - if !ok { - return nil, false +// evictForSpaceLocked makes room in a full store: expired entries first, then — +// if everything is still live — the single oldest entry. Callers must hold +// requestContextMu. +func evictForSpaceLocked(now time.Time) { + if n := sweepLocked(now); n > 0 { + log.Printf("[consent-filter] request-context store full (%d), evicted %d expired entr(ies)", MaxRequestContexts, n) + return + } + oldestKey, oldestAt := "", time.Time{} + for key, entry := range requestContextStore { + if oldestAt.IsZero() || entry.storedAt.Before(oldestAt) { + oldestKey, oldestAt = key, entry.storedAt + } + } + if oldestKey != "" { + delete(requestContextStore, oldestKey) + contextsEvicted++ + log.Printf("[consent-filter] request-context store full (%d) with no expired entries, evicted the oldest", MaxRequestContexts) } +} - ctx, ok := val.(*RequestContext) - if !ok { - return nil, false +// sweepRequestContexts removes every entry stored more than RequestContextTTL +// before now and returns how many were removed. +func sweepRequestContexts(now time.Time) int { + requestContextMu.Lock() + defer requestContextMu.Unlock() + return sweepLocked(now) +} + +// sweepLocked is sweepRequestContexts for a caller already holding the mutex. +func sweepLocked(now time.Time) int { + evicted := 0 + for key, entry := range requestContextStore { + if now.Sub(entry.storedAt) > RequestContextTTL { + delete(requestContextStore, key) + evicted++ + } } + contextsEvicted += uint64(evicted) + return evicted +} - return ctx, true +// RequestContextStoreSize reports how many request contexts are currently held. +// It is the gauge that makes a leak observable: in a healthy runner it tracks +// the number of in-flight gated requests and returns to zero when idle. +func RequestContextStoreSize() int { + requestContextMu.Lock() + defer requestContextMu.Unlock() + return len(requestContextStore) } -// DeleteRequestContext removes the stored RequestContext for the given -// request key. This should be called after the context has been consumed -// during ResponseFilter to prevent memory leaks. -func DeleteRequestContext(requestKey string) { - requestContextStore.Delete(requestKey) +// RequestContextsEvicted reports how many contexts have been evicted because +// they expired or the store was full — i.e. how many requests never reached +// their response phase. +func RequestContextsEvicted() uint64 { + requestContextMu.Lock() + defer requestContextMu.Unlock() + return contextsEvicted } // LoadAndDeleteRequestContext atomically loads and removes the stored -// RequestContext for the given request key. This is the preferred method -// for consuming context during ResponseFilter as it combines retrieval -// and cleanup in a single operation. +// RequestContext for the given request key. This is how the response phase +// consumes a context: retrieval and cleanup in a single operation. An entry that +// has outlived RequestContextTTL is reported as absent (and removed), so a +// stale context can never decide a fresh request. func LoadAndDeleteRequestContext(requestKey string) (*RequestContext, bool) { - val, ok := requestContextStore.LoadAndDelete(requestKey) + requestContextMu.Lock() + defer requestContextMu.Unlock() + + entry, ok := requestContextStore[requestKey] if !ok { return nil, false } - - ctx, ok := val.(*RequestContext) - if !ok { + delete(requestContextStore, requestKey) + if time.Since(entry.storedAt) > RequestContextTTL { + contextsEvicted++ return nil, false } - - return ctx, true + return entry.ctx, true } // String returns a human-readable representation of the RequestContext, // useful for logging and debugging. func (rc *RequestContext) String() string { - return fmt.Sprintf("RequestContext{Method: %s, Path: %s, Claims: %d, Headers: %d}", - rc.Method, rc.Path, len(rc.JWTClaims), len(rc.Headers)) + return fmt.Sprintf("RequestContext{Method: %s, Path: %s, Claims: %d}", + rc.Method, rc.Path, len(rc.JWTClaims)) } diff --git a/internal/plugin/context_test.go b/internal/plugin/context_test.go index 1eba047..9720c73 100644 --- a/internal/plugin/context_test.go +++ b/internal/plugin/context_test.go @@ -19,9 +19,10 @@ package plugin import ( "fmt" - "net/http" + "reflect" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,154 +30,156 @@ import ( // clearContextStore resets the package-level request context store between tests. func clearContextStore() { - requestContextStore.Range(func(key, _ interface{}) bool { - requestContextStore.Delete(key) - return true - }) + requestContextMu.Lock() + defer requestContextMu.Unlock() + requestContextStore = map[string]storedRequestContext{} + contextsEvicted = 0 } -func TestStoreAndLoadRequestContext(t *testing.T) { - clearContextStore() - defer clearContextStore() +// storeAt stores a context as if it had been captured at the given time, so +// expiry can be exercised without waiting for it. +func storeAt(key string, ctx *RequestContext, at time.Time) { + requestContextMu.Lock() + defer requestContextMu.Unlock() + requestContextStore[key] = storedRequestContext{ctx: ctx, storedAt: at} +} +func TestStoreAndLoadAndDeleteRequestContext(t *testing.T) { tests := []struct { name string requestID uint32 ctx *RequestContext }{ { - name: "store and load basic context", + name: "context with claims", requestID: 1, ctx: &RequestContext{ Method: "GET", - Path: "/api/users", - Headers: http.Header{"Authorization": []string{"Bearer token1"}}, - JWTClaims: map[string]interface{}{"sub": "user-1"}, + Path: "/api/users/123", + JWTClaims: map[string]interface{}{"sub": "did:key:z42"}, }, }, { - name: "store and load context with empty claims", + name: "context without claims", requestID: 2, - ctx: &RequestContext{ - Method: "POST", - Path: "/api/data", - Headers: http.Header{}, - JWTClaims: map[string]interface{}{}, - }, - }, - { - name: "store and load context with nil claims", - requestID: 3, - ctx: &RequestContext{ - Method: "DELETE", - Path: "/api/users/123", - Headers: nil, - JWTClaims: nil, - }, + ctx: &RequestContext{Method: "POST", Path: "/api/data"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + clearContextStore() + defer clearContextStore() + StoreRequestContext(testReqKey(tt.requestID), tt.ctx) + assert.Equal(t, 1, RequestContextStoreSize()) - loaded, ok := LoadRequestContext(testReqKey(tt.requestID)) - require.True(t, ok, "expected context to be found") + loaded, ok := LoadAndDeleteRequestContext(testReqKey(tt.requestID)) + require.True(t, ok) assert.Equal(t, tt.ctx, loaded) + + _, ok = LoadAndDeleteRequestContext(testReqKey(tt.requestID)) + assert.False(t, ok, "consuming a context must remove it") + assert.Equal(t, 0, RequestContextStoreSize()) }) } } -func TestLoadRequestContext_NotFound(t *testing.T) { +func TestLoadAndDeleteRequestContext_NotFound(t *testing.T) { clearContextStore() defer clearContextStore() - const nonExistentID uint32 = 99999 - loaded, ok := LoadRequestContext(testReqKey(nonExistentID)) - assert.False(t, ok, "expected context not to be found") + loaded, ok := LoadAndDeleteRequestContext(testReqKey(999)) + assert.False(t, ok) assert.Nil(t, loaded) } -func TestDeleteRequestContext(t *testing.T) { +func TestStoreRequestContext_OverwritesExisting(t *testing.T) { clearContextStore() defer clearContextStore() - const requestID uint32 = 10 - ctx := &RequestContext{ - Method: "GET", - Path: "/api/test", - } + const requestID = uint32(10) + StoreRequestContext(testReqKey(requestID), &RequestContext{Method: "GET", Path: "/first"}) + StoreRequestContext(testReqKey(requestID), &RequestContext{Method: "POST", Path: "/second"}) - StoreRequestContext(testReqKey(requestID), ctx) + assert.Equal(t, 1, RequestContextStoreSize(), "overwriting must not grow the store") - // Verify it's stored. - loaded, ok := LoadRequestContext(testReqKey(requestID)) + loaded, ok := LoadAndDeleteRequestContext(testReqKey(requestID)) require.True(t, ok) - assert.Equal(t, ctx, loaded) - - // Delete it. - DeleteRequestContext(testReqKey(requestID)) - - // Verify it's gone. - loaded, ok = LoadRequestContext(testReqKey(requestID)) - assert.False(t, ok) - assert.Nil(t, loaded) + assert.Equal(t, "/second", loaded.Path) } -func TestLoadAndDeleteRequestContext(t *testing.T) { +// TestRequestContext_ExpiredIsNotServed verifies an entry that outlived the TTL +// is reported as absent (and removed) rather than deciding a fresh request. +func TestRequestContext_ExpiredIsNotServed(t *testing.T) { clearContextStore() defer clearContextStore() - const requestID uint32 = 20 - ctx := &RequestContext{ - Method: "PUT", - Path: "/api/resource", - JWTClaims: map[string]interface{}{"sub": "user-20"}, - } - - StoreRequestContext(testReqKey(requestID), ctx) + key := testReqKey(20) + storeAt(key, &RequestContext{Method: "GET", Path: "/stale"}, time.Now().Add(-RequestContextTTL-time.Second)) - // LoadAndDelete should return the context. - loaded, ok := LoadAndDeleteRequestContext(testReqKey(requestID)) - require.True(t, ok) - assert.Equal(t, ctx, loaded) - - // Subsequent load should fail — already deleted. - loaded, ok = LoadRequestContext(testReqKey(requestID)) - assert.False(t, ok) + loaded, ok := LoadAndDeleteRequestContext(key) + assert.False(t, ok, "an expired context must not be served") assert.Nil(t, loaded) + assert.Equal(t, 0, RequestContextStoreSize()) + assert.Equal(t, uint64(1), RequestContextsEvicted()) } -func TestLoadAndDeleteRequestContext_NotFound(t *testing.T) { +// TestSweepRequestContexts verifies the janitor evicts exactly the entries whose +// response phase never ran, leaving live ones alone. Without it, every aborted +// request leaks an entry for the lifetime of the runner. +func TestSweepRequestContexts(t *testing.T) { clearContextStore() defer clearContextStore() - const nonExistentID uint32 = 88888 - loaded, ok := LoadAndDeleteRequestContext(testReqKey(nonExistentID)) - assert.False(t, ok) - assert.Nil(t, loaded) + now := time.Now() + storeAt("expired-1", &RequestContext{Path: "/a"}, now.Add(-RequestContextTTL-time.Second)) + storeAt("expired-2", &RequestContext{Path: "/b"}, now.Add(-2*RequestContextTTL)) + storeAt("live", &RequestContext{Path: "/c"}, now) + + assert.Equal(t, 2, sweepRequestContexts(now)) + assert.Equal(t, 1, RequestContextStoreSize()) + assert.Equal(t, uint64(2), RequestContextsEvicted()) + + loaded, ok := LoadAndDeleteRequestContext("live") + require.True(t, ok) + assert.Equal(t, "/c", loaded.Path) } -func TestStoreRequestContext_OverwritesExisting(t *testing.T) { +// TestStoreRequestContext_EnforcesCap verifies a full store makes room instead +// of growing without bound — the backstop against a client that opens requests +// and aborts before the response phase. +func TestStoreRequestContext_EnforcesCap(t *testing.T) { clearContextStore() defer clearContextStore() - const requestID uint32 = 30 - original := &RequestContext{ - Method: "GET", - Path: "/original", - } - replacement := &RequestContext{ - Method: "POST", - Path: "/replacement", + now := time.Now() + requestContextMu.Lock() + for i := 0; i < MaxRequestContexts; i++ { + // All live, so no sweep can free anything: the oldest must be evicted. + requestContextStore[fmt.Sprintf("live-%d", i)] = storedRequestContext{ + ctx: &RequestContext{Path: "/x"}, + storedAt: now.Add(time.Duration(i) * time.Millisecond), + } } + requestContextMu.Unlock() - StoreRequestContext(testReqKey(requestID), original) - StoreRequestContext(testReqKey(requestID), replacement) + StoreRequestContext("newest", &RequestContext{Path: "/new"}) - loaded, ok := LoadRequestContext(testReqKey(requestID)) - require.True(t, ok) - assert.Equal(t, replacement, loaded) + assert.Equal(t, MaxRequestContexts, RequestContextStoreSize(), "the store must not exceed its cap") + assert.Equal(t, uint64(1), RequestContextsEvicted()) + _, ok := LoadAndDeleteRequestContext("live-0") + assert.False(t, ok, "the oldest entry is the one evicted") + _, ok = LoadAndDeleteRequestContext("newest") + assert.True(t, ok, "the new request must still be served") +} + +// TestRequestContext_HoldsNoHeaders pins the property that made a leaked entry a +// credential leak: the context must not retain the request's Authorization +// header (or any other). +func TestRequestContext_HoldsNoHeaders(t *testing.T) { + rc := RequestContext{} + assert.Equal(t, 3, reflectFieldCount(rc), "RequestContext must hold only Method, Path and JWTClaims") } func TestRequestContext_String(t *testing.T) { @@ -190,20 +193,14 @@ func TestRequestContext_String(t *testing.T) { ctx: &RequestContext{ Method: "GET", Path: "/api/users", - Headers: http.Header{"Auth": []string{"val"}}, - JWTClaims: map[string]interface{}{"sub": "u1", "scope": "read"}, + JWTClaims: map[string]interface{}{"sub": "u", "scope": "read"}, }, - expected: "RequestContext{Method: GET, Path: /api/users, Claims: 2, Headers: 1}", + expected: "RequestContext{Method: GET, Path: /api/users, Claims: 2}", }, { - name: "empty context", - ctx: &RequestContext{ - Method: "", - Path: "", - Headers: nil, - JWTClaims: nil, - }, - expected: "RequestContext{Method: , Path: , Claims: 0, Headers: 0}", + name: "empty context", + ctx: &RequestContext{}, + expected: "RequestContext{Method: , Path: , Claims: 0}", }, } @@ -214,84 +211,35 @@ func TestRequestContext_String(t *testing.T) { } } -func TestConcurrentStoreAndLoad(t *testing.T) { +func TestConcurrentStoreLoadAndDelete(t *testing.T) { clearContextStore() defer clearContextStore() const goroutineCount = 100 var wg sync.WaitGroup - // Concurrently store contexts with different IDs. for i := uint32(0); i < goroutineCount; i++ { wg.Add(1) go func(id uint32) { defer wg.Done() - ctx := &RequestContext{ + StoreRequestContext(testReqKey(id), &RequestContext{ Method: "GET", Path: fmt.Sprintf("/api/resource/%d", id), JWTClaims: map[string]interface{}{"sub": fmt.Sprintf("user-%d", id)}, - } - StoreRequestContext(testReqKey(id), ctx) + }) }(i) } wg.Wait() + assert.Equal(t, int(goroutineCount), RequestContextStoreSize()) - // Concurrently load and verify all contexts. - for i := uint32(0); i < goroutineCount; i++ { - wg.Add(1) - go func(id uint32) { - defer wg.Done() - loaded, ok := LoadRequestContext(testReqKey(id)) - assert.True(t, ok, "context for ID %d should exist", id) - if ok { - expectedPath := fmt.Sprintf("/api/resource/%d", id) - assert.Equal(t, expectedPath, loaded.Path) - } - }(i) - } - wg.Wait() - - // Concurrently delete all contexts. - for i := uint32(0); i < goroutineCount; i++ { - wg.Add(1) - go func(id uint32) { - defer wg.Done() - DeleteRequestContext(testReqKey(id)) - }(i) - } - wg.Wait() - - // Verify all contexts are gone. - for i := uint32(0); i < goroutineCount; i++ { - _, ok := LoadRequestContext(testReqKey(i)) - assert.False(t, ok, "context for ID %d should have been deleted", i) - } -} - -func TestConcurrentLoadAndDelete(t *testing.T) { - clearContextStore() - defer clearContextStore() - - const goroutineCount = 100 - var wg sync.WaitGroup - - // Pre-populate the store. - for i := uint32(0); i < goroutineCount; i++ { - StoreRequestContext(testReqKey(i), &RequestContext{ - Method: "GET", - Path: fmt.Sprintf("/api/%d", i), - }) - } - - // Concurrently LoadAndDelete — each ID should be successfully loaded - // exactly once across all goroutines. + // Each id must be loaded successfully exactly once across all goroutines. results := make([]bool, goroutineCount) for i := uint32(0); i < goroutineCount; i++ { wg.Add(1) go func(id uint32) { defer wg.Done() - _, ok := LoadAndDeleteRequestContext(testReqKey(id)) - results[id] = ok + loaded, ok := LoadAndDeleteRequestContext(testReqKey(id)) + results[id] = ok && loaded.Path == fmt.Sprintf("/api/resource/%d", id) }(i) } wg.Wait() @@ -299,10 +247,10 @@ func TestConcurrentLoadAndDelete(t *testing.T) { for i := uint32(0); i < goroutineCount; i++ { assert.True(t, results[i], "LoadAndDelete should succeed for ID %d", i) } + assert.Equal(t, 0, RequestContextStoreSize()) +} - // Verify everything is deleted. - for i := uint32(0); i < goroutineCount; i++ { - _, ok := LoadRequestContext(testReqKey(i)) - assert.False(t, ok, "context for ID %d should have been deleted", i) - } +// reflectFieldCount returns how many fields a struct value has. +func reflectFieldCount(v interface{}) int { + return reflect.TypeOf(v).NumField() } From a1eea8eda7c6d6a0aadc1b4b1f5bee4622ec3204 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:51:26 +0200 Subject: [PATCH 07/41] docs: rewrite README and CLAUDE.md against the real config; guard drift in CI (C-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README documented a configuration surface that had been removed from the code months earlier: documented reality client_id / client_secret removed from Config CONSENT_CLIENT_ID/_SECRET env vars are CONSENT_KEY, CONSENT_TOKEN_SERVICE_URL, CONSENT_AUDIT_OTLP_ENDPOINT POST /participants/login fetchToken posts to token_service_url — token_service_url, token_audience, owner_resolver_url, owner_resolver_timeout, service, consumer_claim, consent_api_host all undocumented Because `json.Unmarshal` silently discards unknown fields, an operator copying the README's curl example got a config that parsed and validated cleanly, then could not authenticate as the participant at all. Under the previously documented `fail_open: true` default that meant every request was allowed, the gate entirely bypassed, with nothing to signal it but one log line per request. A documentation defect here is a security defect. - The README's configuration table is rewritten field by field against config.go, the route example is a working `jq`/`curl` invocation carrying the same conf to both phases, and the two-call section describes the actual endpoints, the consumer/purpose scoping and the token-service flow. - New sections state what was implicit and load-bearing: ownership comes from the data and never from the requestor, the JWT is decoded but not verified so an auth plugin in front is a hard prerequisite, and ext-plugin-post-resp buffers the whole upstream response. - CLAUDE.md is refreshed: it described an `internal/filter` package that does not exist, omitted `internal/audit` and `internal/ownerresolver`, and still described the removed client-credentials login. - `TestREADMEDocumentsEveryConfigField` parses the json tags off the Config AST and the README's configuration table and fails if either side has a field the other lacks. Drift on a security control needs a machine, not discipline — and it runs in `make test`, so CI already enforces it. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 96 +++++++++++++------ README.md | 147 +++++++++++++++++++++-------- internal/plugin/config_doc_test.go | 126 +++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 internal/plugin/config_doc_test.go diff --git a/CLAUDE.md b/CLAUDE.md index a18a113..427ecfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,27 @@ # consent-plugin ## Overview -An Apache APISIX Go plugin that gates access to personal data on the data -subject's **consent**. It uses the APISIX go-plugin-runner to hook into the -request/response lifecycle: the request phase (`ext-plugin-pre-req`) captures -the JWT `sub`, and the response phase (`ext-plugin-post-resp`) runs a **two-call -check** against a Prometheus-X / Visions consent-manager (resolve the subject's -`userIdentifier`, then list its consents) and allows the response only when a -granted consent exists — otherwise it replaces the response with a configurable -deny. The two phases are correlated by the Nginx `$request_id` (not the runner's -per-RPC `ID()`). The gate is coarse (allow/deny) and independent of the response -body; there is no field-level filtering. +An Apache APISIX Go plugin that gates access to personal data on the **consent +of the data owner**. It uses the APISIX go-plugin-runner to hook into the +request/response lifecycle: the request phase (`ext-plugin-pre-req`) captures the +JWT claims, and the response phase (`ext-plugin-post-resp`) asks an external +**OwnerResolver** who owns the data in the upstream payload, then runs a +**two-call check** against a Prometheus-X / Visions consent-manager per resolved +owner (resolve the owner's `userIdentifier`, then list its consents) and allows +the response only when every owner has a granted consent **for this consuming +participant**. Otherwise the response is replaced with a configurable deny. + +Ownership never comes from the requestor: the token's `sub` says who is asking, +not whose data is returned. `owner_resolver_url` is therefore required. The two +phases are correlated by the Nginx `$request_id` (not the runner's per-RPC +`ID()`). The gate is coarse (allow/deny) and independent of the response body's +shape; there is no field-level filtering. + +The plugin decodes but does **not verify** the JWT — an authentication plugin +earlier in the route is a hard prerequisite. ## Tech Stack -- Language: Go 1.21+ +- Language: Go (see `go.mod` for the pinned version) - Framework: Apache APISIX go-plugin-runner (`github.com/apache/apisix-go-plugin-runner`) - Test: Go standard `testing` package with `testify` for assertions - Build: Makefile + Docker @@ -22,28 +30,35 @@ body; there is no field-level filtering. ``` consent-plugin/ ├── CLAUDE.md # This file — AI agent codebase context -├── IMPLEMENTATION_PLAN.md # Step-by-step implementation plan -├── README.md # Project README +├── README.md # Project README (the config surface is contract) ├── Makefile # Build, test, lint targets ├── Dockerfile # Build the go-runner binary -├── go.mod # Go module definition -├── go.sum # Go dependency checksums +├── go.mod / go.sum # Go module definition and checksums ├── main.go # Entry point — registers plugin, starts runner ├── internal/ │ ├── plugin/ │ │ ├── consent.go # Plugin struct, Name(), ParseConf(), RequestFilter(), ResponseFilter() +│ │ ├── config.go # Configuration schema and validation +│ │ ├── context.go # Bounded request-context store keyed by $request_id │ │ ├── consent_test.go # Unit tests for plugin logic -│ │ └── config.go # Configuration schema struct and validation +│ │ ├── config_test.go # Unit tests for configuration +│ │ ├── config_doc_test.go # Doc-drift guard: README must document every config field +│ │ └── context_test.go # Unit tests for the context store │ ├── consent/ -│ │ ├── client.go # HTTP client for external consent API -│ │ ├── client_test.go # Unit tests for consent client -│ │ └── models.go # Request/response models for consent API +│ │ ├── client.go # Two-call consent-manager client +│ │ ├── client_test.go # Unit tests for the consent client +│ │ └── models.go # Request/response models for the consent check +│ ├── ownerresolver/ +│ │ ├── client.go # OwnerResolver /resolve client (who owns the data) +│ │ └── client_test.go # Unit tests for the resolver client +│ ├── audit/ +│ │ ├── audit.go # OTLP/HTTP access-decision audit exporter +│ │ └── audit_test.go # Unit tests for the audit exporter │ ├── jwt/ -│ │ ├── extractor.go # JWT extraction and parsing from request headers +│ │ ├── extractor.go # JWT extraction and claim decoding (no verification) │ │ └── extractor_test.go # Unit tests for JWT extraction -│ └── filter/ -│ ├── response.go # JSON response body filtering/redaction logic -│ └── response_test.go # Unit tests for response filtering +│ └── integration/ +│ └── integration_test.go # End-to-end plugin lifecycle tests └── docker-compose.yaml # Local dev with APISIX + plugin runner ``` @@ -75,9 +90,32 @@ make docker-build ## Important Files - `main.go` — Entry point; registers the consent plugin and starts the runner. -- `internal/plugin/consent.go` — Core plugin: `RequestFilter` captures context (keyed by `$request_id`), `ResponseFilter` runs the two-call check and allows/denies. -- `internal/plugin/config.go` — Plugin configuration schema (consent-manager URL + prefix, `consent_key`, participant `client_id`/`client_secret` (or a static `participant_token`), optional `provider_sd`, JWT settings, deny behavior, `fail_open`). `consent_key` is **optional** (the authority's facade injects it and overrides anything sent). `consent_key`/`client_id`/`client_secret` fall back to env vars `CONSENT_KEY`/`CONSENT_CLIENT_ID`/`CONSENT_CLIENT_SECRET` (config wins) so the secret stays out of the route config; `applyEnv()` runs in `ParseConfig`. -- `internal/plugin/context.go` — Concurrent request-context store bridging the two phases, keyed by the Nginx `$request_id`. -- `internal/consent/client.go` — Consent-manager client: participant client-credentials login (`/participants/login`, token cached/refreshed) + provider-SD derivation (`/participants/me`), then the two-call check (`/users/identifier/search` + `/consents/participants/{id}`). Token/SD cache is keyed per participant with a per-entry lock, so concurrent first requests coalesce onto one login without a global lock across the HTTP call. -- `internal/audit/audit.go` — Access-decision audit emitter: exports one OTLP/HTTP log record per decision to the OTel Collector (marked `service.name=consent-access-audit` for routing). Async, batched, best-effort (bounded queue drops rather than blocking); gated by `audit_enabled` + `audit_otlp_endpoint`. `ResponseFilter` → `recordAudit` calls it. -- `go.mod` — Module path: `consent-plugin` (or as configured). +- `internal/plugin/consent.go` — Core plugin: `RequestFilter` captures context + (keyed by `$request_id`), `ResponseFilter` resolves the data owners and runs + the per-owner check. `failMode` distinguishes dependency outages (governed by + `fail_open`) from structural failures that always deny. +- `internal/plugin/config.go` — Plugin configuration schema. `owner_resolver_url` + is **required**; `fail_open` defaults to **false**. `consent_key`, + `token_service_url` and `audit_otlp_endpoint` fall back to `CONSENT_KEY`, + `CONSENT_TOKEN_SERVICE_URL` and `CONSENT_AUDIT_OTLP_ENDPOINT` (config wins) so + secrets stay out of the route config; `applyEnv()` runs in `ParseConfig`. +- `internal/plugin/config_doc_test.go` — Fails the build when the README's + configuration table and the `Config` json tags disagree in either direction. +- `internal/plugin/context.go` — Bounded request-context store bridging the two + phases, keyed by the Nginx `$request_id`: TTL, size cap, background sweep, and + size/eviction gauges. It deliberately holds no request headers. +- `internal/ownerresolver/client.go` — Client for the external OwnerResolver + `/resolve` endpoint, which answers from the DATA alone who the owners are and + whether consent is required. `parties` is for contract identification only. +- `internal/consent/client.go` — Consent-manager client: token from the + participant-local OID4VP token service (`token_service_url`, cached/refreshed + per credential identity) + provider-SD derivation (`/participants/me`), the + participant registry (`/participants`) for DID → self-description mapping, and + the two-call check (`/users/identifier/search` + `/consents/participants/{id}`). + A consent counts only if it is granted **to the named consumer** (and covers + the purpose/resource when known). +- `internal/audit/audit.go` — Access-decision audit emitter: one OTLP/HTTP log + record per decision to the OTel Collector (`service.name=consent-access-audit` + for routing). Async, batched, best-effort; gated by `audit_enabled` + + `audit_otlp_endpoint`. `ResponseFilter` → `recordAudit` calls it. +- `go.mod` — Module path: `consent-plugin`. diff --git a/README.md b/README.md index 9dc1bc5..d377b53 100644 --- a/README.md +++ b/README.md @@ -6,47 +6,91 @@ An Apache APISIX Go plugin that gates access to personal data on the **consent o The plugin is attached to a route in **both** external-plugin phases: -1. **Request phase** (`ext-plugin-pre-req` → `RequestFilter`): captures the request context — method, path, and the JWT claims (notably `sub`) extracted from the configured header — and stores it keyed by the Nginx `$request_id`. -2. **Response phase** (`ext-plugin-post-resp` → `ResponseFilter`): loads that context and runs a **two-call consent check** against the consent-manager for the request subject: - - **allow** (a granted consent exists) — the response passes through unchanged. - - **deny** (no granted consent, or the subject is unknown) — the response is replaced with a configurable error status and body. +1. **Request phase** (`ext-plugin-pre-req` → `RequestFilter`): captures the request context — method, path, and the JWT claims decoded from the configured header — and stores it keyed by the Nginx `$request_id`. +2. **Response phase** (`ext-plugin-post-resp` → `ResponseFilter`): loads that context, asks the **OwnerResolver** who owns the data in the upstream response, and runs a **two-call consent check** per resolved owner: + - **allow** (every resolved owner has a granted consent for this consumer) — the response passes through unchanged. + - **deny** (any owner has no such consent, or is unknown to the consent-manager) — the response is replaced with a configurable error status and body. -The decision is a coarse allow/deny on the subject's consent and is **independent of the response body**, so an empty or non-JSON personal-data response is still gated. If the consent-manager is unreachable, or the plugin's context/credentials are missing, it applies the configured fail-open (default) or fail-closed policy. +The decision is a coarse allow/deny and is **independent of the response body's shape**, so an empty or non-JSON personal-data response is still gated. > The `$request_id` correlation is required: `ext-plugin-pre-req` and `ext-plugin-post-resp` are separate RPCs to the runner and do **not** share the runner's per-call `ID()`. +### Ownership comes from the data, never from the requestor + +`owner_resolver_url` is **required**. The access token's `sub` says who is +*asking*, not whose data is being *returned* — checking the caller's own consent +would let any subject holding a single granted consent read everyone else's +data. So the data owner is always derived from the response payload by the +OwnerResolver, and the token's claims are used only to name the **consuming +participant** for the contract lookup and to scope the consent match. + +> **The plugin does not verify the JWT signature.** It decodes the claims and +> assumes an authentication plugin earlier in the route has already validated the +> token. Attaching `consent-filter` to a route with no authentication in front of +> it is a misconfiguration: the consumer identity would be attacker-supplied. + +### Response buffering + +`ext-plugin-post-resp` forces APISIX to **buffer the entire upstream response** +before the runner sees it (`ReadBody()` is a blocking extra-info RPC over the +unix socket). Gated routes therefore do not stream, and a large response is a +memory multiplier across APISIX and the runner. Keep gated routes to bounded +responses. + ## The two-call consent check The consent-manager has no single "is there consent?" endpoint, so a check is two calls (`{consent_api_url}{consent_api_prefix}` is the base, e.g. `http://consent-manager:3000/v1`): -**1. Resolve the subject to a user identifier** — authenticated with the consent key: +**1. Resolve the owner to a user identifier** — authenticated with the consent key: ``` POST {base}/users/identifier/search Header: x-visionstrust-consent-key: -Body: { "selfDescription": "", "email": "" } -→ { "userIdentifier": "" } (404 / empty ⇒ unknown subject ⇒ deny) +Body: { "selfDescription": "", "email": "" } +→ { "userIdentifier": "" } (404 / empty ⇒ unknown owner ⇒ deny) ``` **2. List that user's consents** — authenticated with the participant JWT: ``` GET {base}/consents/participants/{userIdentifier}?receipt=true Header: Authorization: Bearer -→ { "consents": [ { "status": "granted" | "revoked" | ... } ] } +→ { "consents": [ { "status": "granted", "consumer": {...}, "purposes": [...], "data": [...] } ] } ``` -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). +Access is **allowed** only if a returned consent satisfies all of: -### Participant authentication (client credentials) +- `status == "granted"`; +- it was granted to the **consuming participant** identified from the token (a + consent names one consumer; one granted to X is not authority for Y); +- it covers the **purpose**, when the resolver named one for the claim; +- it covers the **data resource**, when the resolver scoped the claim to one. -Call 2 needs a **participant JWT**, and call 1 needs the **provider self-description**. Rather than pinning a static (expiring) token and a per-registration SD, the plugin obtains both from **participant client credentials**: +The owner DID is sent as the user `email` (the consent-manager's +DID-in-email convention). + +### Participant authentication + +Call 2 needs a **participant JWT**, and call 1 needs the **provider +self-description**. The plugin holds no participant credentials of its own: it +asks the participant-local OID4VP token service (the consent-facade's +`POST /internal/tokens`) for a short-lived token by **audience name**, then +derives the provider SD from `/participants/me`: ``` -POST {base}/participants/login { "clientID": ..., "clientSecret": ... } → { "jwt": ... } (1h token, cached & refreshed) -GET {base}/participants/me Authorization: Bearer → { "selfDescriptionURL": ... } +POST {token_service_url} { "audience": "" } → { "access_token": ..., "expires_in": ... } +GET {base}/participants/me Authorization: Bearer → { "selfDescriptionURL": ... } ``` -So configuring `client_id` + `client_secret` is enough: the token is fetched (and re-fetched on expiry or a 401), and `provider_sd` is derived from `/participants/me` when not set explicitly. Tokens are cached process-wide (keyed by base URL + client id). A static `participant_token` and/or explicit `provider_sd` remain supported as overrides. +The token is cached and refreshed on expiry or a 401. The cache is keyed on the +full credential identity (base URL, host, prefix, audience, token-service URL, +provider SD, and hashes of the static token and consent key), so two routes +fronting different participants against the same consent-manager never share a +token. A static `participant_token` and/or an explicit `provider_sd` remain +supported as overrides for tests and manual runs. + +The consumer DID from the token is translated to a self-description URL via +`GET {base}/participants` (the consent-manager doubles as the participant +registry), cached for 10 minutes — and negatively for 30 seconds, so one +misconfigured DID does not re-fetch the registry on every request. ## Configuration Reference @@ -55,50 +99,69 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `consent_api_url` | `string` | **Yes** | — | Base URL of the consent-manager (e.g. `http://consent-manager:3000`). `http`/`https` only. | -| `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). | -| `consent_key` | `string` | No | — | Shared secret sent as `x-visionstrust-consent-key` on call 1. **Optional**: when the plugin sits behind the authority's facade, the facade injects it server-side (and overrides anything sent here). Falls back to the `CONSENT_KEY` env var. Only needed for a facade-less deployment. | -| `client_id` | `string` | Yes* | — | Participant client id; exchanged (with `client_secret`) for a participant token via `/participants/login`. Falls back to the `CONSENT_CLIENT_ID` env var. | -| `client_secret` | `string` | Yes* | — | Participant client secret. Falls back to the `CONSENT_CLIENT_SECRET` env var, so it need not sit in the route config (etcd). | -| `participant_token_ttl` | `int` | No | `3000` | Seconds a client-credentials token is cached before re-login. | -| `participant_token` | `string` | No | — | *Static* participant JWT override (legacy). Prefer `client_id`/`client_secret`. | -| `provider_sd` | `string` | No | — | Provider self-description URL for call 1. Optional: derived from `/participants/me` when unset. | -| `jwt_header_name` | `string` | No | `Authorization` | Header carrying the JWT. | -| `jwt_claims_to_forward` | `[]string` | No | `[]` | JWT claims to decode. **Must include `sub`** — the subject is resolved from it. | +| `owner_resolver_url` | `string` | **Yes** | — | The OwnerResolver `/resolve` endpoint. The data owner is resolved from the response payload; without it the plugin cannot determine whose consent to check, so the route fails to load. `http`/`https` only. | +| `owner_resolver_timeout` | `int` | No | `2000` | Per-call timeout in ms for `/resolve`. Range 1–60000. | +| `service` | `string` | No | — | Logical dataset id sent to the OwnerResolver as `resource.service`, so it can select the rule for this route. | +| `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Used for the contract lookup and to scope the consent match — never for ownership. | +| `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`. | +| `consent_api_host` | `string` | No | — | Overrides the HTTP `Host` header on consent-manager calls. Needed when `consent_api_url` points at an in-cluster service whose gateway route is host-scoped to the public ingress name. | | `consent_api_timeout` | `int` | No | `5000` | Per-call timeout in ms. Range 1–60000. | +| `consent_key` | `string` | No | — | Shared secret sent as `x-visionstrust-consent-key` on call 1. **Optional**: behind the authority's facade the key is injected server-side (and overrides anything sent here). Falls back to `CONSENT_KEY`. | +| `token_service_url` | `string` | Yes* | — | The participant-local OID4VP token service (the consent-facade's `POST /internal/tokens`). Falls back to `CONSENT_TOKEN_SERVICE_URL`. `http`/`https` only. | +| `token_audience` | `string` | No | `consent-manager` | Audience **name** asked of the token service — its own configured target, not a URL. | +| `participant_token_ttl` | `int` | No | `3000` | Seconds a fetched token is cached. The token service's own `expires_in` wins when shorter. Range 1–86400. | +| `participant_token` | `string` | Yes* | — | *Static*, pre-obtained participant token. An override for tests and manual runs; prefer `token_service_url`, which refreshes automatically. | +| `provider_sd` | `string` | No | — | Provider self-description URL for call 1. Derived from `/participants/me` when unset. | +| `jwt_header_name` | `string` | No | `Authorization` | Header carrying the JWT. | +| `jwt_claims_to_forward` | `[]string` | No | `[]` (all) | Claims to decode and keep. Empty decodes all. The root of `consumer_claim` is always added. | | `deny_status_code` | `int` | No | `403` | Status returned on deny. Range 100–599. | | `deny_response_body` | `string` | No | `{"error":"access denied by consent policy"}` | Body returned on deny. | | `deny_response_content_type` | `string` | No | `application/json` | `Content-Type` for deny responses. | -| `fail_open` | `bool` | No | `true` | On a consent-manager error / missing context / missing credential: `true` passes through, `false` denies. | +| `fail_open` | `bool` | No | `false` | On a **dependency failure** (resolver or consent-manager down/erroring, unreadable body): `false` denies, `true` passes through. Enabling it is logged as a warning at parse time. It does **not** apply to structural failures — no correlation id, no request context, no participant credentials, or "consent required but no owner resolved" — which always deny. | | `audit_enabled` | `bool` | No | `false` | Emit an access-decision audit event (OTLP/HTTP log) to a Collector for every decision. Async + best-effort; never affects the decision. | | `audit_otlp_endpoint` | `string` | Yes† | — | Base OTLP/HTTP endpoint of the Collector (e.g. `http://otel-collector:4318`); `/v1/logs` is appended. Falls back to `CONSENT_AUDIT_OTLP_ENDPOINT`. | | `audit_service_name` | `string` | No | `consent-access-audit` | Resource `service.name` on audit records — the marker the Collector routes on to keep audit logs separate from traces. | -\* Provide **either** `client_id`+`client_secret` (recommended) **or** a static `participant_token`. None are enforced at parse time (the route still loads), but the check cannot succeed without a way to authenticate as the participant: when absent the plugin denies (unless `fail_open` is `true`). +\* Provide **either** `token_service_url` (recommended) **or** a static +`participant_token`. This is enforced at parse time: a route with neither cannot +authenticate as the participant and so cannot gate anything, and is rejected +rather than loaded. † Required only when `audit_enabled` is `true`. -**Credentials via env.** `consent_key`, `client_id` and `client_secret` each fall back to an environment variable (`CONSENT_KEY`, `CONSENT_CLIENT_ID`, `CONSENT_CLIENT_SECRET`) when omitted from the route config; a value in the config always wins. The plugin runner inherits these from the APISIX container, which sources them from a Kubernetes Secret — so the participant secret need not be stored as plaintext in the route config (etcd). +**Credentials via env.** `consent_key`, `token_service_url` and +`audit_otlp_endpoint` each fall back to an environment variable (`CONSENT_KEY`, +`CONSENT_TOKEN_SERVICE_URL`, `CONSENT_AUDIT_OTLP_ENDPOINT`) when omitted from the +route config; a value in the config always wins. The plugin runner inherits these +from the APISIX container, which sources them from a Kubernetes Secret — so +secrets need not be stored as plaintext in the route config (etcd). **Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per decision to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. ## APISIX Route Configuration Example ```bash -curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \ +# Both phases must carry the SAME configuration, so build it once. +PLUGIN_CONF='{"consent_api_url":"http://consent-manager:3000","consent_api_prefix":"/v1","owner_resolver_url":"http://owner-resolver:8080/resolve","service":"personal-profiles","token_service_url":"http://consent-facade:8080/internal/tokens","token_audience":"consent-manager","fail_open":false}' + +jq -n --arg conf "$PLUGIN_CONF" '{ + uri: "/*", + host: "data-service.example.org", + upstream: { type: "roundrobin", nodes: { "backend-service:8080": 1 } }, + plugins: { + "ext-plugin-pre-req": { conf: [ { name: "consent-filter", value: $conf } ] }, + "ext-plugin-post-resp": { conf: [ { name: "consent-filter", value: $conf } ] } + } +}' | curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \ -H "X-API-KEY: your-admin-api-key" \ -H "Content-Type: application/json" \ - -d '{ - "uri": "/*", - "host": "data-service.example.org", - "upstream": { "type": "roundrobin", "nodes": { "backend-service:8080": 1 } }, - "plugins": { - "ext-plugin-pre-req": { "conf": [ { "name": "consent-filter", "value": "{\"consent_api_url\":\"http://consent-manager:3000\",\"consent_api_prefix\":\"/v1\",\"jwt_claims_to_forward\":[\"sub\"],\"consent_key\":\"\",\"client_id\":\"\",\"client_secret\":\"\",\"fail_open\":false}" } ] }, - "ext-plugin-post-resp": { "conf": [ { "name": "consent-filter", "value": "{\"consent_api_url\":\"http://consent-manager:3000\",\"consent_api_prefix\":\"/v1\",\"jwt_claims_to_forward\":[\"sub\"],\"consent_key\":\"\",\"client_id\":\"\",\"client_secret\":\"\",\"fail_open\":false}" } ] } - } - }' + -d @- ``` -Both phases are required: `pre-req` captures the JWT context; `post-resp` performs the check and blocks the response. +`consent_key` is omitted above because the facade injects it; set it (or +`CONSENT_KEY`) for a facade-less deployment. Both phases are required and must +carry the **same** configuration: `pre-req` captures the token context, +`post-resp` performs the check and blocks the response. ## Build and Deployment @@ -140,10 +203,14 @@ consent-plugin/ │ ├── plugin/ │ │ ├── consent.go # RequestFilter + ResponseFilter (the consent gate) │ │ ├── config.go # Configuration schema and validation -│ │ └── context.go # Request context store keyed by $request_id +│ │ └── context.go # Bounded request-context store keyed by $request_id │ ├── consent/ │ │ ├── client.go # Two-call consent-manager client │ │ └── models.go # Request/response models and Decision type +│ ├── ownerresolver/ +│ │ └── client.go # OwnerResolver /resolve client (who owns the data) +│ ├── audit/ +│ │ └── audit.go # OTLP/HTTP access-decision audit exporter │ ├── jwt/ │ │ └── extractor.go # JWT extraction and claim decoding │ └── integration/ diff --git a/internal/plugin/config_doc_test.go b/internal/plugin/config_doc_test.go new file mode 100644 index 0000000..1a35ec3 --- /dev/null +++ b/internal/plugin/config_doc_test.go @@ -0,0 +1,126 @@ +/* + * 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 ( + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readmePath is the README, relative to this package, that must document the +// plugin's configuration surface. +const readmePath = "../../README.md" + +// configSourcePath is the file declaring the Config struct. +const configSourcePath = "config.go" + +// configSectionHeading opens the README section holding the configuration +// table; the section ends at the next top-level heading. +const configSectionHeading = "## Configuration Reference" + +// readmeFieldPattern matches a config field named in the first column of the +// README's configuration table, e.g. "| `consent_api_url` | ...". +var readmeFieldPattern = regexp.MustCompile("(?m)^\\|\\s*`([a-z0-9_]+)`\\s*\\|") + +// TestREADMEDocumentsEveryConfigField guards against documentation drift on a +// security control. +// +// This repository has already shipped a README describing `client_id` / +// `client_secret` months after those fields were removed from Config. Because +// json.Unmarshal silently discards unknown fields, a config copied from that +// README parsed and validated cleanly and then could not authenticate at all — +// a documentation defect that was, in effect, a security defect. Drift on this +// surface needs a machine, not discipline. +func TestREADMEDocumentsEveryConfigField(t *testing.T) { + documented := documentedConfigFields(t) + declared := declaredConfigFields(t) + + for _, field := range declared { + assert.Contains(t, documented, field, + "config field %q is not documented in the README configuration table", field) + } + for field := range documented { + assert.Contains(t, declared, field, + "the README documents %q, which is not a field of Config — a reader would configure something that is silently discarded", field) + } +} + +// documentedConfigFields returns the field names appearing in the README's +// configuration table. +func documentedConfigFields(t *testing.T) map[string]bool { + t.Helper() + readme, err := os.ReadFile(readmePath) + require.NoError(t, err, "the README must be readable to check it for drift") + + // Only the configuration section counts: other tables in the README describe + // unrelated things (compose services, release artifacts). + _, section, found := strings.Cut(string(readme), configSectionHeading) + require.True(t, found, "the README must have a %q section", configSectionHeading) + if next := strings.Index(section, "\n## "); next >= 0 { + section = section[:next] + } + + fields := map[string]bool{} + for _, match := range readmeFieldPattern.FindAllStringSubmatch(section, -1) { + fields[match[1]] = true + } + require.NotEmpty(t, fields, "no configuration table found in the README") + return fields +} + +// declaredConfigFields returns the json tag names of every field of Config, read +// from the source rather than by reflection so that the check does not depend on +// a field being exported or populated. +func declaredConfigFields(t *testing.T) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), configSourcePath, nil, 0) + require.NoError(t, err) + + var fields []string + ast.Inspect(file, func(n ast.Node) bool { + typeSpec, ok := n.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != "Config" { + return true + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return false + } + for _, field := range structType.Fields.List { + if field.Tag == nil { + continue + } + tag := reflect.StructTag(strings.Trim(field.Tag.Value, "`")).Get("json") + if name := strings.Split(tag, ",")[0]; name != "" && name != "-" { + fields = append(fields, name) + } + } + return false + }) + require.NotEmpty(t, fields, "no json-tagged fields found on Config") + return fields +} From 258a1c11b6645c156de910061bc682d8f5a21960 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:53:26 +0200 Subject: [PATCH 08/41] fix(plugin): never fail open on a consumer missing from the participant registry (H-1/H-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the fail-mode classification. A consumer DID that the participant registry does not know was mapped to failByPolicy, so with `fail_open: true` it released the data. But "not registered" is not an outage: it is a permanent condition, and retrying will not change it. Failing it open does not ride out a blip — it grants that consumer standing, indefinite access to personal data through a gate that is nominally enabled. `ParticipantSelfDescriptionByDID` now wraps a new `consent.ErrParticipantNotRegistered` sentinel, and `failModeForError` treats it like `ErrNoCredentials`: always closed, regardless of `fail_open`. An unreachable or erroring registry stays under the operator's policy, which is the genuine availability case. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 16 ++++++++++++++-- internal/plugin/consent.go | 8 +++++--- internal/plugin/consent_test.go | 8 +++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index 672c145..52454e6 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -90,6 +90,12 @@ var errParticipantUnauthorized = errors.New("consent client: participant token u // mistyped route config becomes a silent, total bypass of the gate. var ErrNoCredentials = errors.New("consent client: no participant_token and no token_service_url configured") +// ErrParticipantNotRegistered signals that the participant registry answered and +// holds no participant for the requested DID. Like ErrNoCredentials it is a +// permanent condition rather than an outage: retrying will not fix it, so +// callers must not treat it as a transient error to be failed open on. +var ErrParticipantNotRegistered = errors.New("consent client: participant not registered") + // ClientConfig holds everything needed to verify consent against the // (Prometheus-X / Visions) consent-manager. type ClientConfig struct { @@ -424,7 +430,7 @@ func (c *Client) ParticipantSelfDescriptionByDID(ctx context.Context, did string participantSDMu.Unlock() if hit && time.Now().Before(entry.expiry) { if entry.unknown { - return "", fmt.Errorf("consent client: no participant registered for did %q", did) + return "", notRegistered(did) } return entry.selfDescriptionURL, nil } @@ -452,11 +458,17 @@ func (c *Client) ParticipantSelfDescriptionByDID(ctx context.Context, did string participantSDMu.Unlock() if sd == "" { - return "", fmt.Errorf("consent client: no participant registered for did %q", did) + return "", notRegistered(did) } return sd, nil } +// notRegistered builds the "no such participant" error, wrapping the sentinel so +// callers can tell a misconfigured DID from an unreachable registry. +func notRegistered(did string) error { + return fmt.Errorf("%w: no participant registered for did %q", ErrParticipantNotRegistered, did) +} + // lookupParticipantSD fetches the participant registry and returns the // self-description URL registered for did, or "" when the registry answered but // holds no such participant (a definite negative, not an error). forceLogin diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index fabe387..baa9383 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -392,11 +392,13 @@ func failOutcome(cfg *Config, mode failMode, reason, requestID string, req *cons return o } -// failModeForError maps a dependency error to its fail mode: a missing -// credential is a misconfiguration that must never be failed open on, anything +// failModeForError maps a dependency error to its fail mode. A missing +// credential or a consumer that is not in the participant registry is a +// permanent misconfiguration: retrying will not fix it, so failing it open would +// not ride out an outage, it would grant that consumer standing access. Anything // else is treated as an outage the operator's policy governs. func failModeForError(err error) failMode { - if errors.Is(err, consent.ErrNoCredentials) { + if errors.Is(err, consent.ErrNoCredentials) || errors.Is(err, consent.ErrParticipantNotRegistered) { return failAlwaysClosed } return failByPolicy diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 6fb3977..d954b21 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -493,9 +493,11 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { }, }) }, - consentServer: newUncalledConsentManager, - resolverServer: newUncalledOwnerResolver, - configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, + consentServer: newUncalledConsentManager, + resolverServer: newUncalledOwnerResolver, + // A consumer that is not in the registry is a permanent condition, so + // fail_open must not grant it standing access. + configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(true) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, }, From 57b0e06d46afde596ec5d6cb2e8c4a3bbd50f7aa Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 10:54:59 +0200 Subject: [PATCH 09/41] test: cover the owner-resolver path and gate coverage in CI (H-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver path — the sound one, the one every other critical finding lives in — had 0% coverage. `evaluateWithResolver`, `consumerFromClaims`, `resourceOrPath`, `ParticipantSelfDescriptionByDID`, `decodeParticipants` and `ProviderSelfDescription` were all measured at 0.0%, while the legacy mode that has now been deleted was the one with eleven end-to-end cases behind it. Coverage was also mis-measured. `make test-cover` and tests.yml both omitted `-coverpkg=./...`, so the integration package's coverage of internal/plugin was discarded outright — which understated the real number and, worse, made the genuine 0% functions look like measurement noise. And the profile was uploaded as an artifact and never asserted, so nothing would have noticed the gap. - `internal/integration` gains a resolver-mode harness: a mock `/resolve` and a per-owner consent-manager, driving multi-owner deny_all, an owner unknown to the consent-manager, `consentRequired: false`, empty claims, a claim with an empty ownerId, resolver 5xx under both fail policies, and owner dedup. - Two further integration tests pin what the plugin actually tells the resolver (resource descriptor, mapped contract parties, JSON payload) and the H-1 fail-closed seam (an unidentified consumer never reaches the resolver and denies even with fail_open). - `consent.ResetCaches()` gives tests a reset hook for the package-wide token and participant-SD caches. They passed only because httptest happens to allocate a fresh base URL per server; a reused URL or `t.Parallel()` would have produced order-dependent flakes. - Unit tests for `claimKeysToDecode` and `decodeParticipants` (both registry shapes), and `TestConfig_IsFailOpen` moves to config_test.go where the rest of the config tests live. - `-coverpkg=./...` in `make test-cover` and tests.yml, plus `hack/coverage-floor.sh` and a `coverage-floor` target enforcing an 80% floor in both CI pipelines. Total coverage: 69.1% -> 85.7%. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 8 +- Makefile | 18 +- hack/coverage-floor.sh | 29 +++ internal/consent/client_test.go | 63 +++-- internal/consent/reset.go | 38 +++ internal/integration/integration_test.go | 285 +++++++++++++++++++++++ internal/plugin/config_test.go | 66 ++++++ internal/plugin/consent_test.go | 19 -- 8 files changed, 488 insertions(+), 38 deletions(-) create mode 100755 hack/coverage-floor.sh create mode 100644 internal/consent/reset.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2b870df..5628b3d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,12 +15,18 @@ jobs: with: go-version-file: go.mod + # -coverpkg=./... attributes the integration package's coverage of the + # packages it exercises; without it those statements are discarded and the + # reported figure understates reality. - name: Run tests (race + coverage) - run: go test -race -coverprofile=coverage.out ./... + run: go test -race -coverpkg=./... -coverprofile=coverage.out ./... - name: Coverage summary run: go tool cover -func=coverage.out + - name: Enforce the coverage floor + run: make coverage-floor + - uses: actions/upload-artifact@v4 with: name: coverage diff --git a/Makefile b/Makefile index d514548..3517b9f 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,11 @@ GO_BUILD_FLAGS := -trimpath -ldflags="-s -w" # Coverage output file COVERAGE_FILE := coverage.out -.PHONY: build test test-cover lint license-check license-fix docker-build clean +# Minimum total statement coverage, in percent. CI and `make test-cover` fail +# below it, so a gap cannot reappear unnoticed. +COVERAGE_FLOOR := 80 + +.PHONY: build test test-cover coverage-floor lint license-check license-fix docker-build clean ## build: Compile the go-runner binary build: @@ -23,10 +27,18 @@ build: test: go test -race ./... -## test-cover: Run tests with coverage report +## test-cover: Run tests with coverage report and enforce the floor +# -coverpkg=./... is required: without it the integration package's coverage of +# internal/plugin is discarded, which understated the real figure and made the +# genuinely untested functions look like measurement noise. test-cover: - go test -race -coverprofile=$(COVERAGE_FILE) ./... + go test -race -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./... go tool cover -func=$(COVERAGE_FILE) + ./hack/coverage-floor.sh $(COVERAGE_FILE) $(COVERAGE_FLOOR) + +## coverage-floor: Assert an existing coverage profile meets COVERAGE_FLOOR +coverage-floor: + ./hack/coverage-floor.sh $(COVERAGE_FILE) $(COVERAGE_FLOOR) ## lint: Run golangci-lint lint: diff --git a/hack/coverage-floor.sh b/hack/coverage-floor.sh new file mode 100755 index 0000000..9df35a6 --- /dev/null +++ b/hack/coverage-floor.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Fail when total statement coverage is below a floor. +# +# Coverage was previously uploaded as a CI artifact and never asserted, so a +# whole code path could sit at 0% without anything noticing. This turns the +# number into a gate. +# +# Usage: coverage-floor.sh + +set -euo pipefail + +profile="${1:?usage: coverage-floor.sh }" +floor="${2:?usage: coverage-floor.sh }" + +total="$(go tool cover -func="${profile}" | awk '/^total:/ {gsub(/%/, "", $3); print $3}')" + +if [[ -z "${total}" ]]; then + echo "coverage-floor: could not read a total from ${profile}" >&2 + exit 1 +fi + +# awk rather than bash arithmetic: the percentages are fractional. +if awk -v total="${total}" -v floor="${floor}" 'BEGIN { exit !(total < floor) }'; then + echo "coverage-floor: total coverage ${total}% is below the ${floor}% floor" >&2 + exit 1 +fi + +echo "coverage-floor: total coverage ${total}% meets the ${floor}% floor" diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 8198c94..470e0c5 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -169,21 +169,8 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { return srv } -// resetCredCache clears the package-wide credential cache between tests. -func resetCredCache() { - credCacheMu.Lock() - credCache = map[string]*cacheEntry{} - credCacheMu.Unlock() - resetParticipantSDCache() -} - -// resetParticipantSDCache clears the package-wide DID -> self-description cache -// between tests, so a positive or negative result cannot leak across them. -func resetParticipantSDCache() { - participantSDMu.Lock() - participantSDCache = map[string]participantSDEntry{} - participantSDMu.Unlock() -} +// resetCredCache clears the package-wide caches between tests. +func resetCredCache() { ResetCaches() } // TestCheckConsent_HostOverride verifies the configured Host header is sent to // the consent-manager (for host-scoped gateway routes) while the connection @@ -731,3 +718,49 @@ func TestParticipantSelfDescriptionByDID_NegativeCaching(t *testing.T) { } assert.Equal(t, 1, registryCalls, "an unknown DID must be remembered, not re-fetched per request") } + +// TestDecodeParticipants verifies both registry shapes are accepted: the +// consent-manager has returned a bare array and a wrapped list at different +// times, and a plugin that understands only one silently loses the ability to +// identify the consumer. +func TestDecodeParticipants(t *testing.T) { + tests := []struct { + name string + body string + want []participantListEntry + wantErr bool + }{ + { + name: "bare array", + body: `[{"did":"did:key:zA","selfDescriptionURL":"http://catalog/a"}]`, + want: []participantListEntry{{DID: "did:key:zA", SelfDescriptionURL: "http://catalog/a"}}, + }, + { + name: "wrapped list", + body: `{"participants":[{"did":"did:key:zB","selfDescriptionURL":"http://catalog/b"}]}`, + want: []participantListEntry{{DID: "did:key:zB", SelfDescriptionURL: "http://catalog/b"}}, + }, + { + name: "empty array", + body: `[]`, + want: []participantListEntry{}, + }, + { + name: "not JSON at all", + body: `gateway error`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeParticipants([]byte(tt.body)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/consent/reset.go b/internal/consent/reset.go new file mode 100644 index 0000000..cd40db4 --- /dev/null +++ b/internal/consent/reset.go @@ -0,0 +1,38 @@ +/* + * 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 + +// ResetCaches drops every package-wide cache: the participant credentials +// (token + derived provider self-description) and the DID -> self-description +// mappings. +// +// It exists for tests. They currently pass only because httptest allocates a +// fresh base URL per server, which happens to produce a fresh cache key; a test +// that reuses a URL, or one that runs in parallel with another, would otherwise +// see another test's token and fail in an order-dependent way. Production code +// must not call this: dropping a live token mid-flight only causes a re-fetch, +// but there is no reason to. +func ResetCaches() { + credCacheMu.Lock() + credCache = map[string]*cacheEntry{} + credCacheMu.Unlock() + + participantSDMu.Lock() + participantSDCache = map[string]participantSDEntry{} + participantSDMu.Unlock() +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index ef58cde..03facda 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -22,6 +22,7 @@ package integration import ( + "consent-plugin/internal/consent" "consent-plugin/internal/plugin" "context" "encoding/base64" @@ -32,6 +33,8 @@ import ( "net/http/httptest" "net/url" "strconv" + "strings" + "sync" "testing" pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http" @@ -250,6 +253,45 @@ func newFailingConsentManager(status int) *httptest.Server { return httptest.NewServer(mux) } +// resolveEnvelope is the /resolve request the plugin sends, decoded so tests can +// assert on what the resolver was actually told. +type resolveEnvelope struct { + Resource struct { + Service string `json:"service"` + Method string `json:"method"` + Path string `json:"path"` + ContentType string `json:"contentType"` + } `json:"resource"` + Parties *struct { + Consumer string `json:"consumer"` + Provider string `json:"provider"` + } `json:"parties"` + Body *struct { + Encoding string `json:"encoding"` + Content json.RawMessage `json:"content"` + } `json:"body"` +} + +// newRecordingOwnerResolver starts a mock OwnerResolver that answers with the +// given JSON and records every envelope it received. +func newRecordingOwnerResolver(t *testing.T, reply map[string]interface{}, received *[]resolveEnvelope) *httptest.Server { + t.Helper() + var mu sync.Mutex + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var env resolveEnvelope + if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + t.Errorf("failed to decode resolve request: %v", err) + } + mu.Lock() + *received = append(*received, env) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(reply); err != nil { + t.Errorf("failed to encode resolve response: %v", err) + } + })) +} + // newOwnerResolver starts a mock OwnerResolver that reports the given data // owners for every payload. With no owners, it reports that no consent is // required. @@ -603,3 +645,246 @@ func TestIntegration_TokenServiceDenied(t *testing.T) { assert.Equal(t, 403, resp.writtenStatus) assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) } + +// --- Resolver-mode integration tests --- + +// perOwnerConsentManager starts a consent-manager whose consent status is looked +// up per data owner, so a multi-owner response can mix granted and revoked +// owners. It records the owners it was asked about, in order. +func perOwnerConsentManager(t *testing.T, statusByOwner map[string]string, asked *[]string) *httptest.Server { + t.Helper() + var mu sync.Mutex + identifiers := map[string]string{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + owner := body["email"] + mu.Lock() + *asked = append(*asked, owner) + status, known := statusByOwner[owner] + if known { + identifiers["uid-"+owner] = status + } + mu.Unlock() + if !known { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-" + owner}) + }) + mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/v1/consents/participants/") + mu.Lock() + status := identifiers[id] + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consents": consentsGrantedTo([]string{status})}) + }) + mux.HandleFunc("/v1/participants", participantRegistryHandler) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestIntegration_Resolver drives the resolver-mode decision matrix end to end: +// a real ParseConf -> RequestFilter -> ResponseFilter against a mock resolver +// and a mock consent-manager. +func TestIntegration_Resolver(t *testing.T) { + const ( + ownerAlice = "did:key:zAlice" + ownerBob = "did:key:zBob" + ) + + tests := []struct { + name string + resolverReply map[string]interface{} + resolverCode int // non-zero => the resolver answers with this status instead + statusByOwner map[string]string + failOpen *bool + wantDenied bool + wantOwners []string // owners the consent-manager must have been asked about + }{ + { + name: "every owner granted allows", + resolverReply: resolveReply(ownerAlice, ownerBob), + statusByOwner: map[string]string{ownerAlice: "granted", ownerBob: "granted"}, + wantOwners: []string{ownerAlice, ownerBob}, + }, + { + name: "one revoked owner denies the whole response (deny_all)", + resolverReply: resolveReply(ownerAlice, ownerBob), + statusByOwner: map[string]string{ownerAlice: "granted", ownerBob: "revoked"}, + wantDenied: true, + wantOwners: []string{ownerAlice, ownerBob}, + }, + { + name: "an owner unknown to the consent-manager denies", + resolverReply: resolveReply(ownerAlice, ownerBob), + statusByOwner: map[string]string{ownerAlice: "granted"}, + wantDenied: true, + }, + { + name: "consentRequired false allows without any consent call", + resolverReply: map[string]interface{}{"consentRequired": false}, + wantOwners: nil, + }, + { + name: "consent required with no claims denies", + resolverReply: map[string]interface{}{"consentRequired": true, "claims": []map[string]string{}}, + wantDenied: true, + wantOwners: nil, + }, + { + name: "a claim with an empty owner denies", + resolverReply: map[string]interface{}{ + "consentRequired": true, + "claims": []map[string]string{{"ownerId": ""}}, + }, + wantDenied: true, + wantOwners: nil, + }, + { + name: "resolver 5xx denies by default", + resolverCode: http.StatusInternalServerError, + wantDenied: true, + wantOwners: nil, + }, + { + name: "resolver 5xx passes through with fail_open", + resolverCode: http.StatusInternalServerError, + failOpen: boolPtr(true), + wantOwners: nil, + }, + { + name: "duplicate owners are checked once", + resolverReply: resolveReply(ownerAlice, ownerAlice, ownerAlice), + statusByOwner: map[string]string{ownerAlice: "granted"}, + wantOwners: []string{ownerAlice}, + }, + } + + // Each case needs its own request id, so the context store entries cannot + // collide between subtests. + nextRequestID := uint32(100) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + consent.ResetCaches() + nextRequestID++ + + var asked []string + cm := perOwnerConsentManager(t, tt.statusByOwner, &asked) + + var resolver *httptest.Server + if tt.resolverCode != 0 { + resolver = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.resolverCode) + })) + } else { + var received []resolveEnvelope + resolver = newRecordingOwnerResolver(t, tt.resolverReply, &received) + } + defer resolver.Close() + + cfg := baseConfig(cm.URL, resolver.URL+"/resolve") + if tt.failOpen != nil { + cfg["fail_open"] = *tt.failOpen + } + + resp := runPluginCycle(t, marshalConfig(t, cfg), + consentRequest(nextRequestID, "did:key:zCaller"), []byte(`{"id":"urn:entity:1"}`)) + + if tt.wantDenied { + assert.Equal(t, 403, resp.writtenStatus) + assert.Equal(t, defaultDenyBody, string(resp.writtenBody)) + } else { + assert.Nil(t, resp.writtenBody, "expected the response to pass through") + assert.Equal(t, 0, resp.writtenStatus) + } + if tt.wantOwners != nil { + assert.Equal(t, tt.wantOwners, asked, "the consent-manager must be asked about exactly these owners") + } + }) + } +} + +// resolveReply builds a /resolve reply requiring consent from the given owners. +func resolveReply(owners ...string) map[string]interface{} { + claims := make([]map[string]string, 0, len(owners)) + for _, o := range owners { + claims = append(claims, map[string]string{"ownerId": o}) + } + return map[string]interface{}{"consentRequired": true, "claims": claims} +} + +// TestIntegration_ResolverReceivesPartiesAndPayload verifies what the plugin +// actually tells the resolver: the resource descriptor, the contract parties +// (resolved via the participant registry), and the upstream payload as JSON. +func TestIntegration_ResolverReceivesPartiesAndPayload(t *testing.T) { + consent.ResetCaches() + + var asked []string + cm := perOwnerConsentManager(t, map[string]string{"did:key:zAlice": "granted"}, &asked) + + var received []resolveEnvelope + resolver := newRecordingOwnerResolver(t, resolveReply("did:key:zAlice"), &received) + defer resolver.Close() + + cfg := baseConfig(cm.URL, resolver.URL+"/resolve") + cfg["service"] = "personal-profiles" + + payload := []byte(`{"id":"urn:ngsi-ld:PersonalProfile:alice"}`) + runPluginCycle(t, marshalConfig(t, cfg), consentRequest(150, "did:key:zCaller"), payload) + + require.Len(t, received, 1) + env := received[0] + assert.Equal(t, "personal-profiles", env.Resource.Service) + assert.Equal(t, "GET", env.Resource.Method) + assert.Equal(t, "/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:alice", env.Resource.Path) + require.NotNil(t, env.Parties, "the contract parties must be sent, or the resolver cannot identify the contract") + assert.Equal(t, itestConsumerSD, env.Parties.Consumer, "the consumer DID must be mapped to its self-description") + assert.Equal(t, "http://consent-facade:8080/participants/org-itest", env.Parties.Provider) + require.NotNil(t, env.Body) + assert.Equal(t, "json", env.Body.Encoding) + assert.JSONEq(t, string(payload), string(env.Body.Content)) +} + +// TestIntegration_PartyResolutionFailureDenies verifies the fail-closed seam +// from H-1 end to end: when the consumer cannot be mapped to a participant, the +// resolver is never asked and the response is denied — even with fail_open, +// which must not turn a token naming an unregistered consumer into a bypass. +func TestIntegration_PartyResolutionFailureDenies(t *testing.T) { + consent.ResetCaches() + + var asked []string + cm := perOwnerConsentManager(t, map[string]string{}, &asked) + + resolverCalled := false + resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resolverCalled = true + w.Header().Set("Content-Type", "application/json") + // An unidentified-party resolve could plausibly answer "no contract + // governs this, so no consent is required" — an unconditional allow. + _ = json.NewEncoder(w).Encode(map[string]interface{}{"consentRequired": false}) + })) + defer resolver.Close() + + h := newMockRequestHeader() + h.Set("Authorization", "Bearer "+buildMockJWT(map[string]interface{}{ + "verifiableCredential": map[string]interface{}{"issuer": "did:key:zUnregisteredConsumer"}, + })) + req := &mockRequest{id: 160, method: "GET", path: []byte("/data"), header: h} + + cfg := baseConfig(cm.URL, resolver.URL+"/resolve") + cfg["fail_open"] = true + + resp := runPluginCycle(t, marshalConfig(t, cfg), req, []byte(`{"a":1}`)) + + assert.False(t, resolverCalled, "an unidentified consumer must not reach the resolver") + assert.Equal(t, 403, resp.writtenStatus, "an unidentified consumer must deny") +} + +// boolPtr returns a pointer to b, for the optional fail_open flag. +func boolPtr(b bool) *bool { return &b } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index 6d5396e..c268845 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -436,3 +436,69 @@ func TestConsentFilter_ParseConf_Integration(t *testing.T) { assert.Nil(t, conf) }) } + +func TestConfig_IsFailOpen(t *testing.T) { + tests := []struct { + name string + failOpen *bool + want bool + }{ + {name: "nil defaults to false (fail-closed)", failOpen: nil, want: false}, + {name: "explicitly true is fail-open", failOpen: boolPtr(true), want: true}, + {name: "explicitly false is fail-closed", failOpen: boolPtr(false), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{FailOpen: tt.failOpen} + assert.Equal(t, tt.want, cfg.IsFailOpen()) + }) + } +} + +// TestClaimKeysToDecode verifies which claims the request phase decodes: the +// configured forward list plus the root of the consumer-claim path, so the +// consuming participant can still be read in the response phase. +func TestClaimKeysToDecode(t *testing.T) { + tests := []struct { + name string + forward []string + consumer string + want []string + }{ + { + name: "empty forward list decodes every claim", + want: nil, + }, + { + name: "the consumer-claim root is added to the forward list", + forward: []string{"sub"}, + consumer: "verifiableCredential.issuer", + want: []string{"sub", "verifiableCredential"}, + }, + { + name: "an already-listed root is not added twice", + forward: []string{"sub", "verifiableCredential"}, + consumer: "verifiableCredential.issuer", + want: []string{"sub", "verifiableCredential"}, + }, + { + name: "a single-segment consumer claim is its own root", + forward: []string{"sub"}, + consumer: "issuer", + want: []string{"sub", "issuer"}, + }, + { + name: "no consumer claim leaves the forward list alone", + forward: []string{"sub"}, + want: []string{"sub"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := claimKeysToDecode(&Config{JWTClaimsToForward: tt.forward, ConsumerClaim: tt.consumer}) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index d954b21..fcdaf70 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -704,22 +704,3 @@ func TestResponseFilter_OwnerNotRequestor(t *testing.T) { assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, "the owner has no granted consent, so the caller's own consent must not unlock the data") } - -func TestConfig_IsFailOpen(t *testing.T) { - tests := []struct { - name string - failOpen *bool - want bool - }{ - {name: "nil defaults to false (fail-closed)", failOpen: nil, want: false}, - {name: "explicitly true is fail-open", failOpen: boolPtr(true), want: true}, - {name: "explicitly false is fail-closed", failOpen: boolPtr(false), want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{FailOpen: tt.failOpen} - assert.Equal(t, tt.want, cfg.IsFailOpen()) - }) - } -} From 1f79e03d7d4eabe4ef5a74c5e6f22e3b4a02e190 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:01:27 +0200 Subject: [PATCH 10/41] perf(plugin): bound the response-phase fan-out (H-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop over the resolver's claims ran one full two-call consent check per distinct (owner, dataResource), sequentially, each on context.Background() with its own per-call timeout. A collection endpoint returning 200 entities with 200 distinct owners therefore issued ~400 sequential HTTP calls; at the default 5s per-call timeout the worst case was ~2000s of held-open response while APISIX buffered the body. The client and APISIX give up long before that, but the runner's goroutine keeps working and its connections stay open, so a handful of such requests saturate both the runner and the consent-manager. Any caller who can reach a list endpoint could trigger it. Four changes, one per limb of the problem: (a) One `context.WithTimeout` for the whole response phase, derived once and passed to the party lookups, the resolve call and every consent check. The total time APISIX holds the response is now bounded regardless of owner count, and a cancelled phase propagates instead of leaving work running. New `response_phase_timeout` (default 10000ms, range 1-120000). (b) `max_owners_per_response` (default 50, range 1-1000) caps the distinct owners checked. Above it the response is denied — a deliberate limit, so it denies even under `fail_open`. (c) The per-owner checks run concurrently, at most 8 in flight, and cancel the rest on the first denial or error, since nothing the others could return would change a deny_all verdict. The reported outcome is always the lowest-indexed problem, so the decision and its audit record do not depend on which goroutine won the race. (d) The subject -> userIdentifier mapping is memoised for 60s, so one owner appearing under several data resources is resolved once rather than once per resource. Only positive results are cached: a subject can register at any moment, and remembering "unknown" would keep denying them after they had consented. Claim dedup moves into `distinctClaims`, which also turns a claim with no owner into an error rather than an inline early return. Tests: the owner cap denying even with fail_open, the phase deadline bounding a consent-manager that never answers, `distinctClaims` as a table, and the identifier memo (reused for a second resource, never reused for an unknown subject). Co-Authored-By: Claude Opus 5 --- README.md | 8 +- internal/consent/client.go | 47 ++++++- internal/consent/client_test.go | 44 ++++++ internal/consent/reset.go | 8 +- internal/integration/integration_test.go | 6 +- internal/plugin/config.go | 51 +++++++ internal/plugin/config_test.go | 10 +- internal/plugin/consent.go | 169 +++++++++++++++++++---- internal/plugin/consent_test.go | 121 ++++++++++++++++ 9 files changed, 425 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d377b53..217b558 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,11 @@ participant** for the contract lookup and to scope the consent match. before the runner sees it (`ReadBody()` is a blocking extra-info RPC over the unix socket). Gated routes therefore do not stream, and a large response is a memory multiplier across APISIX and the runner. Keep gated routes to bounded -responses. +responses, and note that `response_phase_timeout` and `max_owners_per_response` +(below) bound how long the response is held and how many owners are checked. + +Per-owner consent checks run concurrently (up to 8 in flight) and short-circuit +on the first denial, so latency is not the sum over owners. ## The two-call consent check @@ -102,6 +106,8 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `owner_resolver_url` | `string` | **Yes** | — | The OwnerResolver `/resolve` endpoint. The data owner is resolved from the response payload; without it the plugin cannot determine whose consent to check, so the route fails to load. `http`/`https` only. | | `owner_resolver_timeout` | `int` | No | `2000` | Per-call timeout in ms for `/resolve`. Range 1–60000. | | `service` | `string` | No | — | Logical dataset id sent to the OwnerResolver as `resource.service`, so it can select the rule for this route. | +| `response_phase_timeout` | `int` | No | `10000` | Budget in ms for the **entire** response phase — party lookups, `/resolve`, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. | +| `max_owners_per_response` | `int` | No | `50` | Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. | | `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Used for the contract lookup and to scope the consent match — never for ownership. | | `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`. | | `consent_api_host` | `string` | No | — | Overrides the HTTP `Host` header on consent-manager calls. Needed when `consent_api_url` points at an in-cluster service whose gateway route is host-scoped to the public ingress name. | diff --git a/internal/consent/client.go b/internal/consent/client.go index 52454e6..ca43bfd 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -378,6 +378,33 @@ func (c *Client) credentials(ctx context.Context, forceFetch bool) (token, provi return token, providerSD, nil } +// identifierCacheTTL bounds how long a (provider, subject) -> userIdentifier +// mapping is reused. The mapping is stable for the life of the registration, so +// a short TTL is enough to collapse the repeated searches a single multi-owner +// response would otherwise make, without holding a stale identifier. +// +// Only POSITIVE results are cached. "Unknown subject" must be re-asked every +// time: a data subject can register at any moment, and remembering that they +// were unknown would keep denying them after they had consented. +const identifierCacheTTL = 60 * time.Second + +type identifierEntry struct { + userIdentifier string + expiry time.Time +} + +var ( + identifierMu sync.Mutex + identifierCache = map[string]identifierEntry{} +) + +// identifierCacheKey scopes a cached identifier to the credential identity and +// the provider it was resolved for - the identifier is provider-scoped, so it +// must never be reused across providers. +func (c *Client) identifierCacheKey(providerSD, subject string) string { + return strings.Join([]string{c.cacheKey(), providerSD, subject}, credentialKeySeparator) +} + // 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. @@ -650,11 +677,23 @@ type identifierSearchResponse struct { // user "email") to the provider-scoped user identifier. A 404 or empty identifier // means the subject is unknown (found == false). // +// A positive result is cached for identifierCacheTTL, so a response resolving to +// the same owner under several data resources searches once instead of once per +// resource. +// // It carries both the shared consent key (which the consent-manager's // consentKeyCheck validates) and the participant token as a Bearer credential, // so an authenticating facade in front of the consent-manager can validate the // participant JWT on this call too (the consent-manager ignores the Bearer here). func (c *Client) resolveUserIdentifier(ctx context.Context, subject, providerSD, token string) (identifier string, found bool, err error) { + cacheKey := c.identifierCacheKey(providerSD, subject) + identifierMu.Lock() + entry, hit := identifierCache[cacheKey] + identifierMu.Unlock() + if hit && time.Now().Before(entry.expiry) { + return entry.userIdentifier, true, nil + } + payload, err := json.Marshal(map[string]string{"selfDescription": providerSD, "email": subject}) if err != nil { return "", false, fmt.Errorf("consent client: failed to marshal identifier search: %w", err) @@ -694,7 +733,13 @@ func (c *Client) resolveUserIdentifier(ctx context.Context, subject, providerSD, if err := json.Unmarshal(body, &out); err != nil { return "", false, fmt.Errorf("consent client: failed to unmarshal identifier search response: %w", err) } - return out.UserIdentifier, out.UserIdentifier != "", nil + if out.UserIdentifier == "" { + return "", false, nil + } + identifierMu.Lock() + identifierCache[cacheKey] = identifierEntry{userIdentifier: out.UserIdentifier, expiry: time.Now().Add(identifierCacheTTL)} + identifierMu.Unlock() + return out.UserIdentifier, true, nil } // participantConsentsResponse is the consent-manager response to call 2. The diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 470e0c5..b38b32f 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -764,3 +764,47 @@ func TestDecodeParticipants(t *testing.T) { }) } } + +// TestResolveUserIdentifier_MemoisedPerSubject verifies the subject -> +// userIdentifier mapping is reused, so one response resolving to the same owner +// under several data resources searches once instead of once per resource. +func TestResolveUserIdentifier_MemoisedPerSubject(t *testing.T) { + resetCredCache() + m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, resourcesPerConsent: [][]string{{"r1", "r2"}}} + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static", ProviderSD: "sd"}) + + for _, resource := range []string{"r1", "r2"} { + resp, err := c.CheckConsent(context.Background(), + ConsentRequest{Subject: "did:key:zOwner", Consumer: testConsumerSD, DataResource: resource}) + require.NoError(t, err) + assert.Equal(t, DecisionAllow, resp.Decision) + } + + m.mu.Lock() + defer m.mu.Unlock() + assert.Equal(t, 1, m.searchCalls, "the same owner must be resolved to an identifier once") + assert.Equal(t, 2, m.consentsCalls, "each resource still needs its own consent decision") +} + +// TestResolveUserIdentifier_UnknownSubjectNotCached verifies an unknown subject +// is re-asked every time. A data subject can register at any moment, and +// remembering that they were unknown would keep denying them after they had +// consented. +func TestResolveUserIdentifier_UnknownSubjectNotCached(t *testing.T) { + resetCredCache() + m := &mockCM{userID: "", statuses: nil} + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static", ProviderSD: "sd"}) + + for i := 0; i < 3; i++ { + resp, err := c.CheckConsent(context.Background(), + ConsentRequest{Subject: "did:key:zStranger", Consumer: testConsumerSD}) + require.NoError(t, err) + assert.Equal(t, DecisionDeny, resp.Decision) + } + + m.mu.Lock() + defer m.mu.Unlock() + assert.Equal(t, 3, m.searchCalls, "an unknown subject must not be cached as unknown") +} diff --git a/internal/consent/reset.go b/internal/consent/reset.go index cd40db4..ad27c91 100644 --- a/internal/consent/reset.go +++ b/internal/consent/reset.go @@ -18,8 +18,8 @@ package consent // ResetCaches drops every package-wide cache: the participant credentials -// (token + derived provider self-description) and the DID -> self-description -// mappings. +// (token + derived provider self-description), the DID -> self-description +// mappings, and the subject -> user-identifier mappings. // // It exists for tests. They currently pass only because httptest allocates a // fresh base URL per server, which happens to produce a fresh cache key; a test @@ -35,4 +35,8 @@ func ResetCaches() { participantSDMu.Lock() participantSDCache = map[string]participantSDEntry{} participantSDMu.Unlock() + + identifierMu.Lock() + identifierCache = map[string]identifierEntry{} + identifierMu.Unlock() } diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 03facda..e0b0a76 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -718,7 +718,8 @@ func TestIntegration_Resolver(t *testing.T) { resolverReply: resolveReply(ownerAlice, ownerBob), statusByOwner: map[string]string{ownerAlice: "granted", ownerBob: "revoked"}, wantDenied: true, - wantOwners: []string{ownerAlice, ownerBob}, + // Which owners get asked depends on the concurrent short-circuit, so + // only the decision is asserted here. }, { name: "an owner unknown to the consent-manager denies", @@ -804,7 +805,8 @@ func TestIntegration_Resolver(t *testing.T) { assert.Equal(t, 0, resp.writtenStatus) } if tt.wantOwners != nil { - assert.Equal(t, tt.wantOwners, asked, "the consent-manager must be asked about exactly these owners") + // Checks run concurrently, so the order is not significant. + assert.ElementsMatch(t, tt.wantOwners, asked, "the consent-manager must be asked about exactly these owners") } }) } diff --git a/internal/plugin/config.go b/internal/plugin/config.go index f500240..040d6fb 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -37,6 +37,28 @@ const ( // OwnerResolver calls. DefaultOwnerResolverTimeout = 2000 + // DefaultResponsePhaseTimeout is the default budget in milliseconds for the + // ENTIRE response phase - the party lookups, the resolve call and every + // per-owner consent check together. APISIX holds the buffered response for + // this whole time, so it must be bounded independently of the per-call + // timeouts, which multiply by the number of owners. + DefaultResponsePhaseTimeout = 10000 + + // DefaultMaxOwnersPerResponse is the default cap on how many distinct data + // owners are checked for one response. A collection endpoint returning + // hundreds of entities would otherwise issue hundreds of consent checks + // before answering. + DefaultMaxOwnersPerResponse = 50 + + // MinMaxOwnersPerResponse and MaxMaxOwnersPerResponse bound the cap itself. + MinMaxOwnersPerResponse = 1 + MaxMaxOwnersPerResponse = 1000 + + // MinResponsePhaseTimeout and MaxResponsePhaseTimeout bound the response-phase + // budget in milliseconds (120s is already far beyond any sane gateway timeout). + MinResponsePhaseTimeout = 1 + MaxResponsePhaseTimeout = 120000 + // 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 @@ -151,6 +173,19 @@ type Config struct { // OwnerResolver (defaults to DefaultOwnerResolverTimeout). OwnerResolverTimeout int `json:"owner_resolver_timeout,omitempty"` + // ResponsePhaseTimeout bounds, in milliseconds, the whole response phase: + // the party lookups, the resolve call and every per-owner consent check + // together. Without it the worst case is the per-call timeout multiplied by + // the number of owners, all while APISIX holds the buffered response. + // Defaults to DefaultResponsePhaseTimeout. + ResponsePhaseTimeout int `json:"response_phase_timeout,omitempty"` + + // MaxOwnersPerResponse caps how many distinct data owners are checked for a + // single response. A response resolving to more owners than this is denied + // rather than answered after an unbounded number of consent calls. + // Defaults to DefaultMaxOwnersPerResponse. + MaxOwnersPerResponse int `json:"max_owners_per_response,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"` @@ -255,6 +290,12 @@ func (c *Config) applyDefaults() { if c.OwnerResolverTimeout == 0 { c.OwnerResolverTimeout = DefaultOwnerResolverTimeout } + if c.ResponsePhaseTimeout == 0 { + c.ResponsePhaseTimeout = DefaultResponsePhaseTimeout + } + if c.MaxOwnersPerResponse == 0 { + c.MaxOwnersPerResponse = DefaultMaxOwnersPerResponse + } if c.ConsumerClaim == "" { c.ConsumerClaim = DefaultConsumerClaim } @@ -314,6 +355,16 @@ func (c *Config) Validate() error { MinHTTPStatusCode, MaxHTTPStatusCode, c.DenyStatusCode) } + if c.ResponsePhaseTimeout < MinResponsePhaseTimeout || c.ResponsePhaseTimeout > MaxResponsePhaseTimeout { + return fmt.Errorf("config validation: response_phase_timeout must be between %d and %d, got %d", + MinResponsePhaseTimeout, MaxResponsePhaseTimeout, c.ResponsePhaseTimeout) + } + + if c.MaxOwnersPerResponse < MinMaxOwnersPerResponse || c.MaxOwnersPerResponse > MaxMaxOwnersPerResponse { + return fmt.Errorf("config validation: max_owners_per_response must be between %d and %d, got %d", + MinMaxOwnersPerResponse, MaxMaxOwnersPerResponse, c.MaxOwnersPerResponse) + } + if c.AuditEnabled && c.AuditOTLPEndpoint == "" { return errors.New("config validation: audit_otlp_endpoint is required when audit_enabled is true") } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index c268845..d7ad3af 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -355,6 +355,8 @@ func TestConfig_Validate(t *testing.T) { ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: DefaultConsentAPITimeout, OwnerResolverURL: "https://owner-resolver.example.com/resolve", + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, JWTHeaderName: DefaultJWTHeaderName, DenyStatusCode: DefaultDenyStatusCode, DenyResponseBody: DefaultDenyResponseBody, @@ -373,9 +375,11 @@ func TestConfig_Validate(t *testing.T) { { name: "missing owner_resolver_url fails", config: Config{ - ConsentAPIURL: "https://consent.example.com", - ConsentAPITimeout: DefaultConsentAPITimeout, - DenyStatusCode: DefaultDenyStatusCode, + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, }, wantErr: true, errSubstr: "owner_resolver_url is required", diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index baa9383..9e66fab 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -26,9 +26,11 @@ import ( "consent-plugin/internal/ownerresolver" "context" "errors" + "fmt" "log" "net/http" "strings" + "sync" "time" pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http" @@ -228,6 +230,13 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom // distinct (owner, dataResource) claim must have a granted consent, or the whole // response is denied. The requestor identity is never consulted for ownership. func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, key string, reqCtx *RequestContext, consentClient *consent.Client) responseOutcome { + // One deadline for the whole phase. Every call below derives from it, so the + // total time APISIX holds the buffered response is bounded no matter how many + // owners the payload resolves to, and a client that has already given up + // cancels the work rather than leaving it running against the dependencies. + phaseCtx, cancelPhase := context.WithTimeout(context.Background(), time.Duration(cfg.ResponsePhaseTimeout)*time.Millisecond) + defer cancelPhase() + body, err := w.ReadBody() if err != nil { log.Printf("[consent-filter] ResponseFilter: could not read upstream body for request %s: %v", key, err) @@ -255,7 +264,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke log.Printf("[consent-filter] ResponseFilter: no consuming participant in the token claims (path %q) for request %s", cfg.ConsumerClaim, key) return failOutcome(cfg, failAlwaysClosed, "no consuming participant identified", key, nil) } - consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(context.Background(), consumerDID) + consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(phaseCtx, consumerDID) if sdErr != nil { log.Printf("[consent-filter] ResponseFilter: could not map the consumer to a participant for request %s: %v", key, sdErr) return failOutcome(cfg, failModeForError(sdErr), "consumer participant lookup failed: "+sdErr.Error(), key, nil) @@ -265,7 +274,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke // on the strength of it would authorise an agreement the subject never made. resolveParties.Consumer = consumerSD - providerSD, sdErr := consentClient.ProviderSelfDescription(context.Background()) + providerSD, sdErr := consentClient.ProviderSelfDescription(phaseCtx) if sdErr != nil { log.Printf("[consent-filter] ResponseFilter: could not determine the provider self-description for request %s: %v", key, sdErr) return failOutcome(cfg, failModeForError(sdErr), "provider self-description lookup failed: "+sdErr.Error(), key, nil) @@ -273,7 +282,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke resolveParties.Provider = providerSD resolverClient := ownerresolver.NewClient(cfg.OwnerResolverURL, cfg.OwnerResolverTimeout) - result, err := resolverClient.Resolve(context.Background(), ownerresolver.Resource{ + result, err := resolverClient.Resolve(phaseCtx, ownerresolver.Resource{ Service: cfg.Service, Method: reqCtx.Method, Path: reqCtx.Path, @@ -292,42 +301,142 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke return failOutcome(cfg, failAlwaysClosed, "consent required but no data owner resolved", key, nil) } - // deny_all: every distinct (owner, dataResource) claim must be granted. + claims, err := distinctClaims(result.Claims) + if err != nil { + return failOutcome(cfg, failAlwaysClosed, err.Error(), key, nil) + } + if len(claims) > cfg.MaxOwnersPerResponse { + log.Printf("[consent-filter] ResponseFilter: %d distinct data owners for request %s exceeds max_owners_per_response=%d; denying", + len(claims), key, cfg.MaxOwnersPerResponse) + return failOutcome(cfg, failAlwaysClosed, + fmt.Sprintf("response resolves to %d data owners, above max_owners_per_response=%d", len(claims), cfg.MaxOwnersPerResponse), + key, nil) + } + + return checkOwners(phaseCtx, cfg, key, reqCtx, consentClient, claims, consumerSD) +} + +// ownerClaim is one distinct (owner, dataResource) pair to check. +type ownerClaim struct { + owner string + dataResource string + purpose string +} + +// distinctClaims collapses the resolver's claims to the distinct +// (owner, dataResource) pairs that must be checked, preserving the resolver's +// order so the reported denial is stable. A claim naming no owner is an error: +// the resolver said consent is required but not whose. +func distinctClaims(claims []ownerresolver.Claim) ([]ownerClaim, error) { type pair struct{ owner, resource string } - checked := make(map[pair]bool) - for _, claim := range result.Claims { + seen := make(map[pair]bool, len(claims)) + distinct := make([]ownerClaim, 0, len(claims)) + for _, claim := range claims { if claim.OwnerID == "" { - return failOutcome(cfg, failAlwaysClosed, "resolved claim without a data owner", key, nil) + return nil, errors.New("resolved claim without a data owner") } p := pair{owner: claim.OwnerID, resource: claim.DataResource} - if checked[p] { + if seen[p] { continue } - checked[p] = true - - req := consent.ConsentRequest{ - Subject: claim.OwnerID, - Resource: reqCtx.Path, - Method: reqCtx.Method, - DataResource: claim.DataResource, - Consumer: consumerSD, - Purpose: claim.Purpose, - } - 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, failModeForError(err), "consent check error: "+err.Error(), key, &req) + seen[p] = true + distinct = append(distinct, ownerClaim{owner: claim.OwnerID, dataResource: claim.DataResource, purpose: claim.Purpose}) + } + return distinct, nil +} + +// maxConcurrentConsentChecks bounds how many per-owner checks are in flight at +// once. Serial checks made the response latency the sum of every owner's; an +// unbounded fan-out would instead make one response a burst against the +// consent-manager. A small fixed width keeps both bounded. +const maxConcurrentConsentChecks = 8 + +// checkOwners enforces deny_all across the resolved claims: every one must have +// a granted consent for this consumer, or the whole response is denied. +// +// Checks run concurrently up to maxConcurrentConsentChecks and short-circuit on +// the first problem — the remaining calls are cancelled, since nothing they +// could return would change the answer. The reported outcome is always the +// lowest-indexed problem, so the decision (and the audit record) does not depend +// on which goroutine happened to finish first. +func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestContext, client *consent.Client, claims []ownerClaim, consumerSD string) responseOutcome { + type checkResult struct { + outcome responseOutcome + err error + request consent.ConsentRequest + problem bool + attempted bool + } + + results := make([]checkResult, len(claims)) + checksCtx, cancelChecks := context.WithCancel(ctx) + defer cancelChecks() + + slots := make(chan struct{}, maxConcurrentConsentChecks) + var wg sync.WaitGroup + + for i, claim := range claims { + wg.Add(1) + go func(i int, claim ownerClaim) { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-checksCtx.Done(): + return + } + + req := consent.ConsentRequest{ + Subject: claim.owner, + Resource: reqCtx.Path, + Method: reqCtx.Method, + DataResource: claim.dataResource, + Consumer: consumerSD, + Purpose: claim.purpose, + } + results[i].attempted = true + results[i].request = req + + resp, err := client.CheckConsent(checksCtx, req) + switch { + case err != nil: + results[i].err = err + results[i].problem = true + case resp.Decision != consent.DecisionAllow: + results[i].outcome = responseOutcome{ + decision: decisionDeny, + reason: resp.Reason, + requestID: key, + subject: claim.owner, + resource: resourceOrPath(claim.dataResource, reqCtx.Path), + method: reqCtx.Method, + } + results[i].problem = true + } + if results[i].problem { + // Nothing the other owners could say would change a deny_all + // verdict, so stop paying for their calls. + cancelChecks() + } + }(i, claim) + } + wg.Wait() + + for _, result := range results { + if !result.attempted || !result.problem { + continue } - 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, + if result.err != nil { + // A call cancelled because a *different* owner already denied is not + // itself a failure; the deny it lost the race to is reported instead. + if errors.Is(result.err, context.Canceled) && ctx.Err() == nil { + continue } + log.Printf("[consent-filter] ResponseFilter: consent check error for request %s: %v", key, result.err) + req := result.request + return failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) } + return result.outcome } return responseOutcome{decision: decisionAllow, requestID: key, resource: reqCtx.Path, method: reqCtx.Method} } diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index fcdaf70..05f63a2 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -18,11 +18,14 @@ package plugin import ( + "consent-plugin/internal/ownerresolver" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strconv" "testing" + "time" pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http" "github.com/stretchr/testify/assert" @@ -279,6 +282,8 @@ func newTestConfig(consentAPIURL, resolverURL string) *Config { ConsentAPITimeout: DefaultConsentAPITimeout, OwnerResolverURL: resolverURL, OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, ConsumerClaim: DefaultConsumerClaim, JWTHeaderName: DefaultJWTHeaderName, ConsentKey: "test-consent-key", @@ -704,3 +709,119 @@ func TestResponseFilter_OwnerNotRequestor(t *testing.T) { assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, "the owner has no granted consent, so the caller's own consent must not unlock the data") } + +// TestResponseFilter_OwnerCapDenies verifies a response resolving to more data +// owners than the cap is denied outright, rather than answered after an +// unbounded number of consent calls. A collection endpoint returning hundreds of +// entities is otherwise a latency and load amplifier any caller can trigger. +func TestResponseFilter_OwnerCapDenies(t *testing.T) { + clearContextStore() + + owners := make([]string, 0, 5) + for i := 0; i < 5; i++ { + owners = append(owners, fmt.Sprintf("did:key:zOwner%d", i)) + } + + server := newUncalledConsentManager(t) + defer server.Close() + resolver := newOwnerResolver(t, ownedBy(owners...)) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.MaxOwnersPerResponse = 3 + // Even with fail_open the cap must deny: it is a deliberate limit, not an outage. + cfg.FailOpen = boolPtr(true) + + const id = uint32(210) + storeRequest(id) + resp := newMockResponse(id, []byte(`{}`)) + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "more owners than the cap must deny without running the checks") +} + +// TestResponseFilter_ResponsePhaseDeadline verifies the whole response phase is +// bounded: a consent-manager that never answers must not let APISIX hold the +// buffered response for per-call-timeout x owner-count. +func TestResponseFilter_ResponsePhaseDeadline(t *testing.T) { + clearContextStore() + + // The consent-manager never answers, so the phase budget is the only thing + // that can end the wait. + release := make(chan struct{}) + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", participantRegistryHandler) + mux.HandleFunc("/", func(_ http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + }) + server := httptest.NewServer(mux) + // Releasing the handlers must happen BEFORE Close, which waits for them. + defer server.Close() + defer close(release) + + resolver := newOwnerResolver(t, ownedBy("did:key:zA", "did:key:zB", "did:key:zC")) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.ResponsePhaseTimeout = 150 + cfg.ConsentAPITimeout = 60000 // far beyond the phase budget, so the phase budget must win + + const id = uint32(211) + storeRequest(id) + resp := newMockResponse(id, []byte(`{}`)) + + start := time.Now() + (&ConsentFilter{}).ResponseFilter(cfg, resp) + elapsed := time.Since(start) + + assert.Less(t, elapsed, 5*time.Second, "the response phase must be bounded by response_phase_timeout") + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, "a timed-out phase must fail closed by default") +} + +// TestDistinctClaims verifies the resolver's claims collapse to the distinct +// (owner, dataResource) pairs that actually need checking, in resolver order. +func TestDistinctClaims(t *testing.T) { + tests := []struct { + name string + claims []ownerresolver.Claim + want []ownerClaim + wantErr bool + }{ + { + name: "duplicates collapse", + claims: []ownerresolver.Claim{{OwnerID: "a"}, {OwnerID: "a"}, {OwnerID: "b"}}, + want: []ownerClaim{{owner: "a"}, {owner: "b"}}, + }, + { + name: "same owner with different resources stays distinct", + claims: []ownerresolver.Claim{{OwnerID: "a", DataResource: "r1"}, {OwnerID: "a", DataResource: "r2"}}, + want: []ownerClaim{{owner: "a", dataResource: "r1"}, {owner: "a", dataResource: "r2"}}, + }, + { + name: "the purpose is carried through", + claims: []ownerresolver.Claim{{OwnerID: "a", Purpose: "p1"}}, + want: []ownerClaim{{owner: "a", purpose: "p1"}}, + }, + { + name: "a claim without an owner is an error", + claims: []ownerresolver.Claim{{OwnerID: "a"}, {OwnerID: ""}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := distinctClaims(tt.claims) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} From cfd75019f976cc7cd842ac6339baa118ba757f62 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:02:51 +0200 Subject: [PATCH 11/41] fix(plugin): strip upstream headers from the denial response (M-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit denyResponse set Content-Type and the status, wrote the deny body, and touched nothing else — so every other upstream response header survived into the 403. That is a side channel straight around the gate. A caller who was just refused the data still learned that the entity exists and which version it is (ETag / Last-Modified), how many records matched (X-Total-Count, NGSILD-Results-Count), that more pages follow (Link), and was handed whatever Set-Cookie the upstream issued. Content-Encoding was worse than a leak: an upstream that answered `gzip` left the client trying to inflate a plain-JSON deny body, and Content-Length still advertised the upstream body's size. The denial now removes every upstream header except the `Access-Control-*` family — CORS headers describe the exchange rather than the resource, and dropping them would show a browser client a CORS error instead of the 403 it was actually given — then sets Content-Type and a Content-Length computed from the deny body itself. The test mocks also stopped diverging from the runner in ways that hid this: `mockHeader.View()` now returns the live map as the runner's does (so a caller iterating and deleting behaves as in production), and `mockResponse.Write` appends into a buffer rather than replacing, so a double-write regression would now be caught. Co-Authored-By: Claude Opus 5 --- internal/integration/integration_test.go | 4 +- internal/plugin/consent.go | 51 ++++++++++++++++-- internal/plugin/consent_test.go | 67 +++++++++++++++++++----- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index e0b0a76..fe92fcd 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -154,8 +154,10 @@ func (r *mockResponse) Var(name string) ([]byte, error) { } func (r *mockResponse) ReadBody() ([]byte, error) { return r.body, nil } func (r *mockResponse) WriteHeader(statusCode int) { r.writtenStatus = statusCode } + +// Write appends, as the runner's Response.Write does (it writes into a buffer). func (r *mockResponse) Write(b []byte) (int, error) { - r.writtenBody = b + r.writtenBody = append(r.writtenBody, b...) return len(b), nil } diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 9e66fab..bb4d5bb 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -29,6 +29,7 @@ import ( "fmt" "log" "net/http" + "strconv" "strings" "sync" "time" @@ -534,16 +535,58 @@ func recordAudit(cfg *Config, outcome responseOutcome) { }) } -// denyResponse writes a denial response to the client using the configured -// status code, body, and content type. +// deniedResponseHeaderPrefixes are the only upstream response headers allowed to +// survive a denial. CORS headers describe the exchange rather than the resource, +// and dropping them would show a browser client a CORS error instead of the 403 +// it was actually given. +var deniedResponseHeaderPrefixes = []string{"Access-Control-"} + +// denyResponse replaces the upstream response with the configured denial. +// +// Every other upstream header is removed first. A denied caller must not learn +// anything about the data they were refused, and the upstream's headers say +// plenty: Set-Cookie, ETag and Last-Modified (the entity exists, and this is its +// version), Link (there are more pages), and application counters such as +// X-Total-Count or NGSILD-Results-Count (how many records matched) — a side +// channel straight around the gate. Content-Encoding and the upstream's +// Content-Length are also actively wrong once the body is replaced, so +// Content-Length is set to the deny body's own size. func denyResponse(w pkgHTTP.Response, cfg *Config) { - w.Header().Set("Content-Type", cfg.DenyResponseContentType) + body := []byte(cfg.DenyResponseBody) + + header := w.Header() + if view := header.View(); view != nil { + // Collect first: the names are read from the same map Del mutates. + names := make([]string, 0, len(view)) + for name := range view { + names = append(names, name) + } + for _, name := range names { + if !survivesDenial(name) { + header.Del(name) + } + } + } + header.Set("Content-Type", cfg.DenyResponseContentType) + header.Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(cfg.DenyStatusCode) - if _, err := w.Write([]byte(cfg.DenyResponseBody)); err != nil { + if _, err := w.Write(body); err != nil { log.Printf("[consent-filter] ResponseFilter: failed to write deny body for request %d: %v", w.ID(), err) } } +// survivesDenial reports whether an upstream response header may be kept on a +// denial. +func survivesDenial(name string) bool { + for _, prefix := range deniedResponseHeaderPrefixes { + if strings.HasPrefix(http.CanonicalHeaderKey(name), prefix) { + return true + } + } + return false +} + // 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". diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 05f63a2..94870f1 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -89,24 +89,21 @@ func TestPluginName_Constant(t *testing.T) { // --- Mock implementations for testing ResponseFilter --- // mockHeader implements pkgHTTP.Header for testing. +// mockHeader mirrors the runner's header implementation: View() returns the LIVE +// header map (not a copy), so a caller iterating it and deleting through Del +// behaves exactly as it does in production. type mockHeader struct { - headers map[string]string + headers http.Header } func newMockHeader() *mockHeader { - return &mockHeader{headers: make(map[string]string)} + return &mockHeader{headers: make(http.Header)} } -func (h *mockHeader) Set(key, value string) { h.headers[http.CanonicalHeaderKey(key)] = value } -func (h *mockHeader) Del(key string) { delete(h.headers, http.CanonicalHeaderKey(key)) } -func (h *mockHeader) Get(key string) string { return h.headers[http.CanonicalHeaderKey(key)] } -func (h *mockHeader) View() http.Header { - result := make(http.Header) - for k, v := range h.headers { - result[k] = []string{v} - } - return result -} +func (h *mockHeader) Set(key, value string) { h.headers.Set(key, value) } +func (h *mockHeader) Del(key string) { h.headers.Del(key) } +func (h *mockHeader) Get(key string) string { return h.headers.Get(key) } +func (h *mockHeader) View() http.Header { return h.headers } // mockResponse implements pkgHTTP.Response for testing. type mockResponse struct { @@ -140,8 +137,11 @@ func (r *mockResponse) Var(name string) ([]byte, error) { } func (r *mockResponse) ReadBody() ([]byte, error) { return r.body, r.readErr } + +// Write appends, as the runner's Response.Write does (it writes into a buffer). +// A mock that replaced the body would hide a double-write regression. func (r *mockResponse) Write(b []byte) (int, error) { - r.writtenBody = b + r.writtenBody = append(r.writtenBody, b...) return len(b), nil } func (r *mockResponse) WriteHeader(statusCode int) { r.writtenStatus = statusCode } @@ -825,3 +825,44 @@ func TestDistinctClaims(t *testing.T) { }) } } + +// TestDenyResponse_StripsUpstreamHeaders verifies the denial does not inherit the +// upstream's headers. A denied caller must not be told that the entity exists +// (ETag/Last-Modified), how many records matched (X-Total-Count), that more +// pages follow (Link), or be handed a session cookie — that is a side channel +// straight around the gate. Content-Length must also describe the deny body, not +// the upstream's. +func TestDenyResponse_StripsUpstreamHeaders(t *testing.T) { + clearContextStore() + server := newConsentManager(t, "uid-1", []string{"revoked"}) + defer server.Close() + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() + + const id = uint32(220) + storeRequest(id) + + resp := newMockResponse(id, []byte(`{"records":[1,2,3]}`)) + resp.header.Set("ETag", `"v7"`) + resp.header.Set("Last-Modified", "Wed, 27 Aug 2026 10:00:00 GMT") + resp.header.Set("Set-Cookie", "session=abc123") + resp.header.Set("Link", `; rel="next"`) + resp.header.Set("X-Total-Count", "4210") + resp.header.Set("NGSILD-Results-Count", "4210") + resp.header.Set("Content-Encoding", "gzip") + resp.header.Set("Content-Length", "19") + resp.header.Set("Access-Control-Allow-Origin", "https://app.example.org") + + (&ConsentFilter{}).ResponseFilter(newTestConfig(server.URL, resolver.URL+"/resolve"), resp) + + require.Equal(t, DefaultDenyStatusCode, resp.writtenStatus) + for _, leaked := range []string{"ETag", "Last-Modified", "Set-Cookie", "Link", "X-Total-Count", "NGSILD-Results-Count", "Content-Encoding"} { + assert.Empty(t, resp.header.Get(leaked), "%s must not survive a denial", leaked) + } + assert.Equal(t, "https://app.example.org", resp.header.Get("Access-Control-Allow-Origin"), + "CORS headers describe the exchange, not the data, and must survive so the client sees the 403") + assert.Equal(t, DefaultDenyResponseContentType, resp.header.Get("Content-Type")) + assert.Equal(t, strconv.Itoa(len(DefaultDenyResponseBody)), resp.header.Get("Content-Length"), + "Content-Length must describe the deny body, not the upstream's") + assert.Equal(t, DefaultDenyResponseBody, string(resp.writtenBody)) +} From 043bc00dad9833265daa02717ebababca197f981 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:04:38 +0200 Subject: [PATCH 12/41] fix(config): reject a configuration that cannot authenticate or address the API (M-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Validate()` checked URLs, one timeout and the status-code range, but never that the route had any way to authenticate as the participant. The README even documented this as intentional ("None are enforced at parse time"). So a route with neither `token_service_url` nor `participant_token` loaded cleanly and only failed per request, on the data path, as one log line saying "no participant_token and no token_service_url configured". Combined with the old fail-open default that was a route which loaded successfully and allowed everything. A config that cannot complete a single check is not a config worth loading — it is now rejected at parse time. Two more unchecked fields in the same function: - `owner_resolver_timeout` was defaulted but never range-checked, unlike `consent_api_timeout`. Now bounded to 1–60000ms. - `consent_api_prefix` was concatenated into every endpoint URL unchecked, so a prefix without a leading `/` silently produced a malformed URL and every call failed as a confusing 404. It must now start with `/`, and a trailing `/` is trimmed in applyDefaults so it cannot produce a double slash either. The required-field checks also move ahead of the numeric range checks, so a config missing `owner_resolver_url` reports that rather than complaining about a timeout it never got the chance to default. Co-Authored-By: Claude Opus 5 --- README.md | 6 +-- internal/plugin/config.go | 65 ++++++++++++++++++------ internal/plugin/config_test.go | 89 +++++++++++++++++++++++++++++++-- internal/plugin/consent_test.go | 2 +- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 217b558..88c5c36 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `response_phase_timeout` | `int` | No | `10000` | Budget in ms for the **entire** response phase — party lookups, `/resolve`, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. | | `max_owners_per_response` | `int` | No | `50` | Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. | | `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Used for the contract lookup and to scope the consent match — never for ownership. | -| `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`. | +| `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`; a trailing `/` is trimmed. | | `consent_api_host` | `string` | No | — | Overrides the HTTP `Host` header on consent-manager calls. Needed when `consent_api_url` points at an in-cluster service whose gateway route is host-scoped to the public ingress name. | | `consent_api_timeout` | `int` | No | `5000` | Per-call timeout in ms. Range 1–60000. | | `consent_key` | `string` | No | — | Shared secret sent as `x-visionstrust-consent-key` on call 1. **Optional**: behind the authority's facade the key is injected server-side (and overrides anything sent here). Falls back to `CONSENT_KEY`. | @@ -130,8 +130,8 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre \* Provide **either** `token_service_url` (recommended) **or** a static `participant_token`. This is enforced at parse time: a route with neither cannot -authenticate as the participant and so cannot gate anything, and is rejected -rather than loaded. +authenticate as the participant and so cannot complete a single consent check, +and is rejected rather than loaded. † Required only when `audit_enabled` is `true`. diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 040d6fb..e0ac0fb 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -26,6 +26,7 @@ import ( "log" "net/url" "os" + "strings" ) // Default values for optional configuration fields. @@ -59,6 +60,15 @@ const ( MinResponsePhaseTimeout = 1 MaxResponsePhaseTimeout = 120000 + // MinOwnerResolverTimeout and MaxOwnerResolverTimeout bound the per-call + // OwnerResolver timeout in milliseconds. + MinOwnerResolverTimeout = 1 + MaxOwnerResolverTimeout = 60000 + + // apiPrefixSeparator is the path separator an API prefix must start with. A + // prefix without it is silently concatenated into a malformed URL. + apiPrefixSeparator = "/" + // 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 @@ -287,6 +297,11 @@ func (c *Config) applyDefaults() { if c.ConsentAPIPrefix == "" { c.ConsentAPIPrefix = DefaultConsentAPIPrefix } + // The prefix is concatenated directly with the endpoint path, so a trailing + // separator would produce a double slash in every URL. + if c.ConsentAPIPrefix != apiPrefixSeparator { + c.ConsentAPIPrefix = strings.TrimRight(c.ConsentAPIPrefix, apiPrefixSeparator) + } if c.OwnerResolverTimeout == 0 { c.OwnerResolverTimeout = DefaultOwnerResolverTimeout } @@ -345,6 +360,28 @@ func (c *Config) Validate() error { return fmt.Errorf("config validation: consent_api_url must use http or https scheme, got %q", parsedURL.Scheme) } + // The resolver is the only source of data ownership, so a route without one + // cannot gate anything and must not load. + if c.OwnerResolverURL == "" { + return errors.New("config validation: owner_resolver_url is required — " + + "the data owner is resolved from the response data, and without a resolver the plugin cannot determine whose consent to check") + } + 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) + } + + // The prefix is concatenated with the endpoint path rather than joined, so a + // missing leading separator silently yields a malformed URL and every call + // fails with a confusing 404. + if !strings.HasPrefix(c.ConsentAPIPrefix, apiPrefixSeparator) { + return fmt.Errorf("config validation: consent_api_prefix must start with %q, got %q", + apiPrefixSeparator, c.ConsentAPIPrefix) + } + if c.ConsentAPITimeout < MinConsentAPITimeout || c.ConsentAPITimeout > MaxConsentAPITimeout { return fmt.Errorf("config validation: consent_api_timeout must be between %d and %d, got %d", MinConsentAPITimeout, MaxConsentAPITimeout, c.ConsentAPITimeout) @@ -355,6 +392,11 @@ func (c *Config) Validate() error { MinHTTPStatusCode, MaxHTTPStatusCode, c.DenyStatusCode) } + if c.OwnerResolverTimeout < MinOwnerResolverTimeout || c.OwnerResolverTimeout > MaxOwnerResolverTimeout { + return fmt.Errorf("config validation: owner_resolver_timeout must be between %d and %d, got %d", + MinOwnerResolverTimeout, MaxOwnerResolverTimeout, c.OwnerResolverTimeout) + } + if c.ResponsePhaseTimeout < MinResponsePhaseTimeout || c.ResponsePhaseTimeout > MaxResponsePhaseTimeout { return fmt.Errorf("config validation: response_phase_timeout must be between %d and %d, got %d", MinResponsePhaseTimeout, MaxResponsePhaseTimeout, c.ResponsePhaseTimeout) @@ -369,20 +411,6 @@ func (c *Config) Validate() error { return errors.New("config validation: audit_otlp_endpoint is required when audit_enabled is true") } - // The resolver is the only source of data ownership, so a route without one - // cannot gate anything and must not load. - if c.OwnerResolverURL == "" { - return errors.New("config validation: owner_resolver_url is required — " + - "the data owner is resolved from the response data, and without a resolver the plugin cannot determine whose consent to check") - } - 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 { @@ -393,6 +421,15 @@ func (c *Config) Validate() error { } } + // Call 2 is authenticated as the participant, so a route with neither a token + // service nor a static token cannot complete a single check. Loading it + // cleanly and discovering that per request — as one log line, on the data + // path — is how a typo becomes an outage or, with fail_open, a silent bypass. + if c.TokenServiceURL == "" && c.ParticipantToken == "" { + return errors.New("config validation: one of token_service_url or participant_token is required — " + + "without a way to authenticate as the participant no consent check can succeed") + } + return nil } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index d7ad3af..3922af3 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -30,6 +30,7 @@ func validConfigJSON() map[string]interface{} { return map[string]interface{}{ "consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve", + "token_service_url": "https://consent-facade.example.com/internal/tokens", } } @@ -51,7 +52,7 @@ func TestParseConfig(t *testing.T) { }{ { name: "valid config with only required field applies defaults", - input: []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve"}`), + input: []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve", "token_service_url": "https://facade.example.com/internal/tokens"}`), check: func(t *testing.T, cfg *Config) { assert.Equal(t, "https://consent.example.com/api", cfg.ConsentAPIURL) assert.Equal(t, DefaultConsentAPITimeout, cfg.ConsentAPITimeout) @@ -68,6 +69,7 @@ func TestParseConfig(t *testing.T) { m := map[string]interface{}{ "consent_api_url": "http://localhost:8080/consent", "owner_resolver_url": "http://localhost:9090/resolve", + "participant_token": "static-token", "consent_api_timeout": 10000, "jwt_header_name": "X-Auth-Token", "jwt_claims_to_forward": []string{"sub", "scope", "aud"}, @@ -245,7 +247,9 @@ func TestParseConfig_EnvFallback(t *testing.T) { t.Setenv(EnvConsentKey, "ck-from-env") t.Setenv(EnvTokenServiceURL, "http://facade-from-env:8080/internal/tokens") - cfg, err := ParseConfig(toJSON(t, validConfigJSON())) + in := validConfigJSON() + delete(in, "token_service_url") // so the env var is the only source + cfg, err := ParseConfig(toJSON(t, in)) require.NoError(t, err) assert.Equal(t, "ck-from-env", cfg.ConsentKey) assert.Equal(t, "http://facade-from-env:8080/internal/tokens", cfg.TokenServiceURL) @@ -355,6 +359,9 @@ func TestConfig_Validate(t *testing.T) { ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: DefaultConsentAPITimeout, OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + ParticipantToken: "static-token", ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, JWTHeaderName: DefaultJWTHeaderName, @@ -377,6 +384,8 @@ func TestConfig_Validate(t *testing.T) { config: Config{ ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + OwnerResolverTimeout: DefaultOwnerResolverTimeout, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -384,11 +393,59 @@ func TestConfig_Validate(t *testing.T) { wantErr: true, errSubstr: "owner_resolver_url is required", }, + { + name: "no credential source fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "one of token_service_url or participant_token is required", + }, + { + name: "a consent_api_prefix without a leading slash fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: "v1", + OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ParticipantToken: "static-token", + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "consent_api_prefix must start with", + }, + { + name: "an out-of-range owner_resolver_timeout fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: MaxOwnerResolverTimeout + 1, + ParticipantToken: "static-token", + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "owner_resolver_timeout must be between", + }, { name: "negative timeout fails", config: Config{ ConsentAPIURL: "https://consent.example.com", ConsentAPITimeout: -1, + ConsentAPIPrefix: DefaultConsentAPIPrefix, OwnerResolverURL: "https://owner-resolver.example.com/resolve", DenyStatusCode: DefaultDenyStatusCode, }, @@ -417,7 +474,7 @@ func TestConsentFilter_ParseConf_Integration(t *testing.T) { p := &ConsentFilter{} t.Run("valid config returns *Config", func(t *testing.T) { - input := []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve"}`) + input := []byte(`{"consent_api_url": "https://consent.example.com/api", "owner_resolver_url": "https://owner-resolver.example.com/resolve", "token_service_url": "https://facade.example.com/internal/tokens"}`) conf, err := p.ParseConf(input) require.NoError(t, err) @@ -506,3 +563,29 @@ func TestClaimKeysToDecode(t *testing.T) { }) } } + +// TestParseConfig_PrefixNormalisation verifies a trailing separator is trimmed +// rather than concatenated into a double slash in every endpoint URL. +func TestParseConfig_PrefixNormalisation(t *testing.T) { + cases := []struct { + name string + configured string + want string + }{ + {name: "defaults when omitted", configured: "", want: DefaultConsentAPIPrefix}, + {name: "keeps a well-formed prefix", configured: "/v2", want: "/v2"}, + {name: "trims a trailing separator", configured: "/v2/", want: "/v2"}, + {name: "a bare separator is left alone", configured: "/", want: "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := validConfigJSON() + if tc.configured != "" { + in["consent_api_prefix"] = tc.configured + } + cfg, err := ParseConfig(toJSON(t, in)) + require.NoError(t, err) + assert.Equal(t, tc.want, cfg.ConsentAPIPrefix) + }) + } +} diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 94870f1..33b251a 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -45,7 +45,7 @@ func TestConsentFilter_ParseConf(t *testing.T) { }{ { name: "valid config returns parsed Config", - input: []byte(`{"consent_api_url": "https://consent.example.com", "owner_resolver_url": "https://resolver.example.com/resolve"}`), + input: []byte(`{"consent_api_url": "https://consent.example.com", "owner_resolver_url": "https://resolver.example.com/resolve", "participant_token": "t"}`), wantErr: false, }, { From 025113e8c3a8616dad3d6d0304d4661a81f43823 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:07:33 +0200 Subject: [PATCH 13/41] fix(audit): make the access record complete, flushed and safe to export (M-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit trail could not answer the question an access-decision log exists to answer, and lost records on every redeploy. - **It named the wrong owners.** In resolver mode only the *first denying* owner was recorded, and an allow recorded no owner at all — so the log said whether a response was released but not whose consent was consulted or what each said. `responseOutcome` now carries a `checked` entry per consulted owner and `recordAudit` emits one record for each. A request that failed before any owner was reached is still recorded once, as itself. - **The emitter cache ignored half its configuration.** `Get` keyed on `endpoint|serviceName`, so the first route to create an emitter silently imposed its timeout on every other route sharing that Collector. The key is now the full `Config` (endpoint, service name, timeout, headers — header order normalised). - **Nothing was flushed at shutdown.** `Shutdown` existed and was tested but `main()` never called it, so each restart discarded up to one flush interval (2s) of decisions. `audit.ShutdownAll()` is now wired to SIGTERM/SIGINT. - **Upstream error bodies reached the sink.** Reasons are built by wrapping dependency errors, and those embed the consent-manager's response body, which can carry identifiers. `SanitizeReason` (applied inside `Emit`, so no call site can bypass it) collapses control characters and bounds the length. - **Dropped records were invisible.** The bounded queue drops rather than blocking the request path — correct — but that means an attacker who can generate load can suppress the record of their own access. `Emitter.Dropped()` and package-level `Dropped()` expose the count instead of only logging every hundredth drop. - **No way to authenticate to the Collector.** New optional `audit_otlp_headers` config carries extra headers on every export. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- internal/audit/audit.go | 146 +++++++++++++++++++++++++--- internal/audit/audit_test.go | 166 ++++++++++++++++++++++++++++++++ internal/plugin/config.go | 5 + internal/plugin/consent.go | 87 ++++++++++++++--- internal/plugin/consent_test.go | 117 ++++++++++++++++++++++ main.go | 21 +++- 7 files changed, 518 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 88c5c36..4074c58 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `fail_open` | `bool` | No | `false` | On a **dependency failure** (resolver or consent-manager down/erroring, unreadable body): `false` denies, `true` passes through. Enabling it is logged as a warning at parse time. It does **not** apply to structural failures — no correlation id, no request context, no participant credentials, or "consent required but no owner resolved" — which always deny. | | `audit_enabled` | `bool` | No | `false` | Emit an access-decision audit event (OTLP/HTTP log) to a Collector for every decision. Async + best-effort; never affects the decision. | | `audit_otlp_endpoint` | `string` | Yes† | — | Base OTLP/HTTP endpoint of the Collector (e.g. `http://otel-collector:4318`); `/v1/logs` is appended. Falls back to `CONSENT_AUDIT_OTLP_ENDPOINT`. | +| `audit_otlp_headers` | `object` | No | — | Extra HTTP headers sent on every audit export, for a Collector that requires authentication (e.g. `{"Authorization":"Bearer ..."}`). | | `audit_service_name` | `string` | No | `consent-access-audit` | Resource `service.name` on audit records — the marker the Collector routes on to keep audit logs separate from traces. | \* Provide **either** `token_service_url` (recommended) **or** a static @@ -142,7 +143,7 @@ route config; a value in the config always wins. The plugin runner inherits thes from the APISIX container, which sources them from a Kubernetes Secret — so secrets need not be stored as plaintext in the route config (etcd). -**Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per decision to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. +**Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per **checked data owner** (so the log answers whose consent was consulted and what each said, not merely whether the response was released; a request that failed before any owner was reached is recorded once as itself) to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. The queue is flushed on `SIGTERM`/`SIGINT`, so a redeploy does not discard the last flush interval of decisions. Reasons are sanitised before export (control characters collapsed, length bounded) so an upstream error body cannot reach the audit sink verbatim. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. ## APISIX Route Configuration Example diff --git a/internal/audit/audit.go b/internal/audit/audit.go index a8356b4..48e7dc1 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -34,11 +34,13 @@ import ( "encoding/json" "log" "net/http" + "sort" "strconv" "strings" "sync" "sync/atomic" "time" + "unicode" ) // DefaultServiceName is the resource service.name stamped on audit records when @@ -75,6 +77,45 @@ type Config struct { ServiceName string // Timeout bounds a single export HTTP call. Zero defaults to defaultTimeout. Timeout time.Duration + // Headers are extra HTTP headers sent on every export, for a Collector that + // requires authentication (e.g. "Authorization" or a tenant header). + Headers map[string]string +} + +// key identifies the Emitter this configuration describes. Every field that +// changes the emitter's behaviour must appear in it: caching on endpoint and +// service name alone meant the first route's timeout and headers silently +// applied to every other route sharing them. +func (c Config) key() string { + parts := make([]string, 0, 3+2*len(c.Headers)) + parts = append(parts, c.Endpoint, c.serviceName(), c.Timeout.String()) + for _, name := range sortedKeys(c.Headers) { + parts = append(parts, name, c.Headers[name]) + } + return strings.Join(parts, configKeySeparator) +} + +// serviceName is the configured routing marker, or the default. +func (c Config) serviceName() string { + if c.ServiceName == "" { + return DefaultServiceName + } + return c.ServiceName +} + +// configKeySeparator joins the parts of an emitter cache key. It cannot occur in +// a URL, a service name or a header value. +const configKeySeparator = "\x00" + +// sortedKeys returns m's keys in a stable order, so an emitter key does not +// depend on map iteration order. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys } // Event is a single access decision to record. @@ -99,6 +140,7 @@ type Event struct { type Emitter struct { endpoint string serviceName string + headers map[string]string client *http.Client queue chan Event done chan struct{} @@ -113,14 +155,12 @@ var ( ) // Get returns a shared Emitter for cfg, creating (and starting) one on first use. -// Emitters are cached by endpoint + service name, so all routes exporting to the -// same Collector share a single background worker and connection pool. +// Emitters are cached by the full configuration (see Config.key), so all routes +// exporting to the same Collector with the same settings share a single +// background worker and connection pool, while a route configuring a different +// timeout or different headers gets its own. func Get(cfg Config) *Emitter { - sn := cfg.ServiceName - if sn == "" { - sn = DefaultServiceName - } - key := cfg.Endpoint + "|" + sn + key := cfg.key() emittersMu.Lock() defer emittersMu.Unlock() @@ -132,23 +172,45 @@ func Get(cfg Config) *Emitter { return e } +// ShutdownAll flushes and stops every emitter created so far. +// +// The runner is long-lived but not immortal: it is restarted on every redeploy, +// and without this up to defaultFlushInterval of access decisions were lost each +// time — silently, from the record that exists precisely to be complete. Wire it +// to SIGTERM/SIGINT. +func ShutdownAll() { + emittersMu.Lock() + pending := make([]*Emitter, 0, len(emitters)) + for _, e := range emitters { + pending = append(pending, e) + } + emitters = map[string]*Emitter{} + emittersMu.Unlock() + + for _, e := range pending { + e.Shutdown() + } +} + // newEmitter builds and starts an Emitter for cfg. func newEmitter(cfg Config) *Emitter { timeout := cfg.Timeout if timeout <= 0 { timeout = defaultTimeout } - serviceName := cfg.ServiceName - if serviceName == "" { - serviceName = DefaultServiceName - } + serviceName := cfg.serviceName() endpoint := strings.TrimRight(cfg.Endpoint, "/") if !strings.HasSuffix(endpoint, otlpLogsPath) { endpoint += otlpLogsPath } + headers := make(map[string]string, len(cfg.Headers)) + for name, value := range cfg.Headers { + headers[name] = value + } e := &Emitter{ endpoint: endpoint, serviceName: serviceName, + headers: headers, client: &http.Client{Timeout: timeout}, queue: make(chan Event, defaultQueueSize), done: make(chan struct{}), @@ -161,16 +223,73 @@ func newEmitter(cfg Config) *Emitter { // Emit queues ev for export. It never blocks: if the queue is full the event is // dropped and a counter is incremented (data access must not wait on the audit // pipe). Emit is safe for concurrent use. +// +// The reason is sanitised here rather than at the call site, so an upstream +// error body cannot reach the audit sink verbatim no matter which code path +// produced it. func (e *Emitter) Emit(ev Event) { + ev.Reason = SanitizeReason(ev.Reason) select { case e.queue <- ev: default: - if n := e.dropped.Add(1); n%100 == 1 { + if n := e.dropped.Add(1); n%droppedLogEvery == 1 { log.Printf("[consent-filter] audit queue full, dropping event (total dropped %d)", n) } } } +// Dropped reports how many events this emitter has discarded because its queue +// was full. It is the signal that the record is incomplete: an attacker who can +// generate load can suppress the record of their own access, so this number must +// be observable rather than only logged every droppedLogEvery events. +func (e *Emitter) Dropped() uint64 { + return e.dropped.Load() +} + +// Dropped reports the total number of audit events discarded across every +// emitter. +func Dropped() uint64 { + emittersMu.Lock() + defer emittersMu.Unlock() + var total uint64 + for _, e := range emitters { + total += e.Dropped() + } + return total +} + +// maxReasonLength bounds an exported reason. Reasons are short explanations; a +// long one means an upstream error body has been spliced into it. +const maxReasonLength = 200 + +// droppedLogEvery rate-limits the queue-full log line. +const droppedLogEvery = 100 + +// reasonRedaction replaces the tail of an over-long reason. +const reasonRedaction = "...(redacted)" + +// SanitizeReason makes a decision reason safe to export: control characters +// (including the newlines of an HTML or JSON error page) are collapsed to +// spaces, and the result is truncated. +// +// Reasons are built by wrapping dependency errors, and those errors embed the +// consent-manager's response body — which can carry identifiers or other +// personal data. Exporting it verbatim would push exactly the data the audit +// pipeline exists to keep controlled into the audit sink. +func SanitizeReason(reason string) string { + cleaned := strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, reason) + cleaned = strings.Join(strings.Fields(cleaned), " ") + if len(cleaned) > maxReasonLength { + return cleaned[:maxReasonLength-len(reasonRedaction)] + reasonRedaction + } + return cleaned +} + // Shutdown stops the background worker after flushing everything still queued. // Intended for clean teardown and tests; the plugin runner is long-lived and // normally never calls it. @@ -233,6 +352,9 @@ func (e *Emitter) export(batch []Event) { return } req.Header.Set("Content-Type", "application/json") + for name, value := range e.headers { + req.Header.Set(name, value) + } resp, err := e.client.Do(req) if err != nil { log.Printf("[consent-filter] audit: export to %s failed: %v", e.endpoint, err) diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 75035a2..8be4478 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -22,6 +22,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -119,3 +120,168 @@ func TestEmitNeverBlocksWhenQueueFull(t *testing.T) { } assert.GreaterOrEqual(t, e.dropped.Load(), uint64(8), "overflow beyond the queue capacity must be dropped") } + +// TestGetDistinguishesConfigurations verifies the emitter cache keys on the full +// configuration. Keying on endpoint + service name alone meant whichever route +// created the emitter first silently imposed its timeout and headers on every +// other route sharing that Collector. +func TestGetDistinguishesConfigurations(t *testing.T) { + base := Config{Endpoint: "http://collector:4318", ServiceName: "audit", Timeout: time.Second} + + tests := []struct { + name string + mutate func(cfg *Config) + }{ + {"endpoint", func(cfg *Config) { cfg.Endpoint = "http://other:4318" }}, + {"service name", func(cfg *Config) { cfg.ServiceName = "other-audit" }}, + {"timeout", func(cfg *Config) { cfg.Timeout = 5 * time.Second }}, + {"headers", func(cfg *Config) { cfg.Headers = map[string]string{"Authorization": "Bearer x"} }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + other := base + tt.mutate(&other) + assert.NotEqual(t, base.key(), other.key(), + "configurations differing in %s must not share an emitter", tt.name) + }) + } + + t.Run("header order does not matter", func(t *testing.T) { + a := base + a.Headers = map[string]string{"A": "1", "B": "2"} + b := base + b.Headers = map[string]string{"B": "2", "A": "1"} + assert.Equal(t, a.key(), b.key()) + }) + + t.Run("an unnamed service falls back to the default", func(t *testing.T) { + named := Config{Endpoint: "http://collector:4318", ServiceName: DefaultServiceName} + unnamed := Config{Endpoint: "http://collector:4318"} + assert.Equal(t, named.key(), unnamed.key()) + }) +} + +// TestEmitSendsConfiguredHeaders verifies extra headers reach the Collector, so +// an authenticating audit sink can be used. +func TestEmitSendsConfiguredHeaders(t *testing.T) { + received := make(chan http.Header, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + e := newEmitter(Config{Endpoint: srv.URL, Headers: map[string]string{"Authorization": "Bearer audit-token"}}) + e.Emit(Event{Time: time.Now(), Decision: "allow"}) + e.Shutdown() + + select { + case h := <-received: + assert.Equal(t, "Bearer audit-token", h.Get("Authorization")) + assert.Equal(t, "application/json", h.Get("Content-Type")) + case <-time.After(time.Second): + t.Fatal("the Collector never received an export") + } +} + +// TestSanitizeReason verifies an upstream error body spliced into a reason +// cannot reach the audit sink verbatim: control characters are collapsed and the +// result is bounded. +func TestSanitizeReason(t *testing.T) { + tests := []struct { + name string + reason string + want string + }{ + {name: "empty", reason: "", want: ""}, + {name: "plain reason is untouched", reason: "no granted consent", want: "no granted consent"}, + { + name: "newlines and tabs collapse to single spaces", + reason: "consent check error:\n\t{\"error\":\"boom\"}\r\n", + want: `consent check error: {"error":"boom"}`, + }, + { + name: "an over-long reason is truncated and marked", + reason: "x" + strings.Repeat("y", 500), + want: "x" + strings.Repeat("y", maxReasonLength-len(reasonRedaction)-1) + reasonRedaction, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeReason(tt.reason) + assert.Equal(t, tt.want, got) + assert.LessOrEqual(t, len(got), maxReasonLength) + }) + } +} + +// TestEmitSanitizesReason verifies the sanitisation happens on the way out, so +// no call site can bypass it. +func TestEmitSanitizesReason(t *testing.T) { + received := make(chan []byte, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + received <- b + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + e := newEmitter(Config{Endpoint: srv.URL}) + e.Emit(Event{Time: time.Now(), Decision: "deny", Reason: "upstream said:\n{\"email\":\"alice@example.org\"}"}) + e.Shutdown() + + select { + case body := <-received: + assert.NotContains(t, string(body), `\n`, "control characters must not reach the sink") + assert.Contains(t, string(body), "upstream said: ") + case <-time.After(time.Second): + t.Fatal("the Collector never received an export") + } +} + +// TestShutdownAllFlushesEveryEmitter verifies a termination flush drains all +// emitters, so a redeploy does not silently discard queued decisions. +func TestShutdownAllFlushesEveryEmitter(t *testing.T) { + var mu sync.Mutex + var records int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload otlpPayload + _ = json.NewDecoder(r.Body).Decode(&payload) + mu.Lock() + for _, rl := range payload.ResourceLogs { + for _, sl := range rl.ScopeLogs { + records += len(sl.LogRecords) + } + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + for _, serviceName := range []string{"audit-a", "audit-b"} { + Get(Config{Endpoint: srv.URL, ServiceName: serviceName}). + Emit(Event{Time: time.Now(), Decision: "allow", Subject: serviceName}) + } + + ShutdownAll() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 2, records, "every emitter's queue must be flushed on shutdown") +} + +// TestDroppedIsObservable verifies the drop counter is exposed. An attacker who +// can generate load can suppress the record of their own access, so the fact +// that records were lost must be visible, not only logged occasionally. +func TestDroppedIsObservable(t *testing.T) { + e := &Emitter{queue: make(chan Event, 1), done: make(chan struct{}), stopped: make(chan struct{})} + close(e.stopped) // no worker: nothing drains the queue + + for i := 0; i < 5; i++ { + e.Emit(Event{Time: time.Now(), Decision: "allow"}) + } + + assert.Equal(t, uint64(4), e.Dropped(), "one event fits the queue, the rest are dropped and counted") +} diff --git a/internal/plugin/config.go b/internal/plugin/config.go index e0ac0fb..e2e4458 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -270,6 +270,11 @@ type Config struct { // AuditEnabled. Falls back to the EnvAuditOTLPEndpoint env var when empty. AuditOTLPEndpoint string `json:"audit_otlp_endpoint,omitempty"` + // AuditOTLPHeaders are extra HTTP headers sent on every audit export, for a + // Collector that requires authentication (e.g. {"Authorization": "Bearer ..."} + // or a tenant header). + AuditOTLPHeaders map[string]string `json:"audit_otlp_headers,omitempty"` + // AuditServiceName is the resource service.name stamped on audit records - // the marker the Collector routes on to keep audit logs separate from traces. // Empty defaults to the audit package's DefaultServiceName. diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index bb4d5bb..0c09ede 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -154,6 +154,12 @@ const ( // responseOutcome is the result of evaluating consent for one response: the // decision to enforce plus the fields needed to record it in the audit log. +// +// checked carries one entry per data owner whose consent was actually consulted. +// Recording only the outcome answered "was this response allowed?" but not +// "whose consent was checked, and what did each say?" — which is the question an +// access-decision audit log exists to answer. On an allow it named no owner at +// all, and on a deny only the first owner to refuse. type responseOutcome struct { decision string // decisionAllow | decisionDeny reason string @@ -161,6 +167,15 @@ type responseOutcome struct { subject string resource string method string + checked []checkedOwner +} + +// checkedOwner is one data owner's consent decision within a response. +type checkedOwner struct { + subject string + resource string + decision string + reason string } // ResponseFilter gates the upstream response on the data owner's consent. @@ -365,6 +380,7 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo outcome responseOutcome err error request consent.ConsentRequest + record checkedOwner problem bool attempted bool } @@ -397,6 +413,7 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } results[i].attempted = true results[i].request = req + ownerResource := resourceOrPath(claim.dataResource, reqCtx.Path) resp, err := client.CheckConsent(checksCtx, req) switch { @@ -404,15 +421,22 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo results[i].err = err results[i].problem = true case resp.Decision != consent.DecisionAllow: + results[i].record = checkedOwner{ + subject: claim.owner, resource: ownerResource, decision: decisionDeny, reason: resp.Reason, + } results[i].outcome = responseOutcome{ decision: decisionDeny, reason: resp.Reason, requestID: key, subject: claim.owner, - resource: resourceOrPath(claim.dataResource, reqCtx.Path), + resource: ownerResource, method: reqCtx.Method, } results[i].problem = true + default: + results[i].record = checkedOwner{ + subject: claim.owner, resource: ownerResource, decision: decisionAllow, reason: resp.Reason, + } } if results[i].problem { // Nothing the other owners could say would change a deny_all @@ -423,6 +447,15 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } wg.Wait() + // Every owner that was actually consulted is recorded, whatever the verdict, + // so the audit log names them all rather than only the first refusal. + checked := make([]checkedOwner, 0, len(results)) + for _, result := range results { + if result.attempted && result.record.subject != "" { + checked = append(checked, result.record) + } + } + for _, result := range results { if !result.attempted || !result.problem { continue @@ -435,11 +468,15 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } log.Printf("[consent-filter] ResponseFilter: consent check error for request %s: %v", key, result.err) req := result.request - return failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) + outcome := failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) + outcome.checked = checked + return outcome } - return result.outcome + outcome := result.outcome + outcome.checked = checked + return outcome } - return responseOutcome{decision: decisionAllow, requestID: key, resource: reqCtx.Path, method: reqCtx.Method} + return responseOutcome{decision: decisionAllow, requestID: key, resource: reqCtx.Path, method: reqCtx.Method, checked: checked} } // clientConfigFromCfg builds the consent-manager client config from the plugin config. @@ -516,23 +553,47 @@ func failModeForError(err error) failMode { // recordAudit emits the decision to the audit sink when auditing is enabled. // The emit is asynchronous and best-effort, so it never affects the decision. +// +// One record is emitted per data owner whose consent was consulted, so the log +// can answer whose consent was checked and what each said — not merely whether +// the response was released. When no owner was reached (a failure before or +// during resolution) the outcome itself is recorded instead, so the request +// still appears in the record. func recordAudit(cfg *Config, outcome responseOutcome) { if !cfg.AuditEnabled { return } - audit.Get(audit.Config{ + emitter := audit.Get(audit.Config{ Endpoint: cfg.AuditOTLPEndpoint, ServiceName: cfg.AuditServiceName, Timeout: time.Duration(cfg.ConsentAPITimeout) * time.Millisecond, - }).Emit(audit.Event{ - Time: time.Now(), - RequestID: outcome.requestID, - Subject: outcome.subject, - Resource: outcome.resource, - Method: outcome.method, - Decision: outcome.decision, - Reason: outcome.reason, + Headers: cfg.AuditOTLPHeaders, }) + now := time.Now() + + if len(outcome.checked) == 0 { + emitter.Emit(audit.Event{ + Time: now, + RequestID: outcome.requestID, + Subject: outcome.subject, + Resource: outcome.resource, + Method: outcome.method, + Decision: outcome.decision, + Reason: outcome.reason, + }) + return + } + for _, checked := range outcome.checked { + emitter.Emit(audit.Event{ + Time: now, + RequestID: outcome.requestID, + Subject: checked.subject, + Resource: checked.resource, + Method: outcome.method, + Decision: checked.decision, + Reason: checked.reason, + }) + } } // deniedResponseHeaderPrefixes are the only upstream response headers allowed to diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 33b251a..6cc0944 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -18,12 +18,14 @@ package plugin import ( + "consent-plugin/internal/audit" "consent-plugin/internal/ownerresolver" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" + "sync" "testing" "time" @@ -866,3 +868,118 @@ func TestDenyResponse_StripsUpstreamHeaders(t *testing.T) { "Content-Length must describe the deny body, not the upstream's") assert.Equal(t, DefaultDenyResponseBody, string(resp.writtenBody)) } + +// TestRecordAudit_RecordsEveryCheckedOwner verifies the audit log answers whose +// consent was checked and what each said. Recording only the response outcome +// named no owner at all on an allow, and only the first refusal on a deny — +// which is not what an access-decision log is for. +func TestRecordAudit_RecordsEveryCheckedOwner(t *testing.T) { + type record struct { + subject, decision string + } + var mu sync.Mutex + var got []record + + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + ResourceLogs []struct { + ScopeLogs []struct { + LogRecords []struct { + Attributes []struct { + Key string `json:"key"` + Value struct { + StringValue string `json:"stringValue"` + } `json:"value"` + } `json:"attributes"` + } `json:"logRecords"` + } `json:"scopeLogs"` + } `json:"resourceLogs"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("failed to decode OTLP payload: %v", err) + } + mu.Lock() + for _, rl := range payload.ResourceLogs { + for _, sl := range rl.ScopeLogs { + for _, lr := range sl.LogRecords { + var rec record + for _, attr := range lr.Attributes { + switch attr.Key { + case "enduser.id": + rec.subject = attr.Value.StringValue + case "consent.decision": + rec.decision = attr.Value.StringValue + } + } + got = append(got, rec) + } + } + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + cfg := &Config{ + AuditEnabled: true, + AuditOTLPEndpoint: collector.URL, + AuditServiceName: "consent-access-audit-test", + ConsentAPITimeout: DefaultConsentAPITimeout, + } + recordAudit(cfg, responseOutcome{ + decision: decisionDeny, + requestID: "req-1", + method: "GET", + checked: []checkedOwner{ + {subject: "did:key:zA", resource: "/r", decision: decisionAllow}, + {subject: "did:key:zB", resource: "/r", decision: decisionDeny, reason: "no granted consent"}, + }, + }) + audit.ShutdownAll() + + mu.Lock() + defer mu.Unlock() + assert.ElementsMatch(t, []record{ + {subject: "did:key:zA", decision: decisionAllow}, + {subject: "did:key:zB", decision: decisionDeny}, + }, got, "every consulted owner must appear in the audit log, not only the one that denied") +} + +// TestRecordAudit_RecordsOutcomeWhenNoOwnerReached verifies a request that +// failed before any owner was consulted still appears in the record. +func TestRecordAudit_RecordsOutcomeWhenNoOwnerReached(t *testing.T) { + var mu sync.Mutex + records := 0 + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + ResourceLogs []struct { + ScopeLogs []struct { + LogRecords []json.RawMessage `json:"logRecords"` + } `json:"scopeLogs"` + } `json:"resourceLogs"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + mu.Lock() + for _, rl := range payload.ResourceLogs { + for _, sl := range rl.ScopeLogs { + records += len(sl.LogRecords) + } + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + cfg := &Config{ + AuditEnabled: true, + AuditOTLPEndpoint: collector.URL, + AuditServiceName: "consent-access-audit-no-owner", + ConsentAPITimeout: DefaultConsentAPITimeout, + } + recordAudit(cfg, responseOutcome{decision: decisionDeny, requestID: "req-2", reason: "owner resolver error"}) + audit.ShutdownAll() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, records, "a failure before any owner was reached must still be recorded") +} diff --git a/main.go b/main.go index 87413ba..9f6999c 100644 --- a/main.go +++ b/main.go @@ -21,12 +21,31 @@ package main import ( + "consent-plugin/internal/audit" + // Import the plugin package to trigger init() registration. _ "consent-plugin/internal/plugin" + "os" + "os/signal" + "syscall" "github.com/apache/apisix-go-plugin-runner/pkg/runner" - // Import the plugin package to trigger init() registration. ) func main() { + // The audit queue is flushed by a background worker on an interval, so a + // redeploy or restart would otherwise discard up to one flush interval of + // access decisions — silently, from the record whose whole purpose is to be + // complete. runner.Run blocks, so the flush is driven from its own goroutine. + go flushAuditOnShutdown() + runner.Run(runner.RunnerConfig{}) } + +// flushAuditOnShutdown waits for a termination signal and flushes every audit +// emitter before the process goes away. +func flushAuditOnShutdown() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + <-signals + audit.ShutdownAll() +} From ad39a252694e1598d0195c9fd50d8c7fe7513c9a Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:08:47 +0200 Subject: [PATCH 14/41] fix(plugin): traverse arrays in the consumer claim path (M-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `consumerFromClaims` walked a dotted path through `map[string]interface{}` only. The default path is `verifiableCredential.issuer`, but a Verifiable Presentation routinely carries `verifiableCredential` as a JSON **array** — so on an ordinary VP token the type assertion failed, "" came back, and the function logged nothing. The consumer was simply absent, which now (post H-1) denies every such request with no indication of why, and previously fed the unidentified-party allow seam. - A bare segment landing on an array traverses its first element, so the default path works on the common shape without any configuration. - Explicit indexing is supported — `verifiableCredential[0].issuer`, including repeated indices for nested arrays (`a[0][0].b`). - The function returns an error rather than "", naming the segment that failed and distinguishing "no path configured" (`errClaimPathUnset`) from "the configured path did not resolve". A mistyped path is now diagnosable from the log line instead of looking like a consumer that simply is not there. - `claimKeysToDecode` uses a shared `claimPathRoot` so an indexed first segment still resolves to the top-level claim the request phase must decode. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- internal/plugin/consent.go | 143 +++++++++++++++++++++++++++----- internal/plugin/consent_test.go | 119 ++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4074c58..6852728 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `service` | `string` | No | — | Logical dataset id sent to the OwnerResolver as `resource.service`, so it can select the rule for this route. | | `response_phase_timeout` | `int` | No | `10000` | Budget in ms for the **entire** response phase — party lookups, `/resolve`, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. | | `max_owners_per_response` | `int` | No | `50` | Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. | -| `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Used for the contract lookup and to scope the consent match — never for ownership. | +| `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Supports array indexing (`verifiableCredential[0].issuer`), and a bare segment landing on an array traverses its first element — a Verifiable Presentation routinely carries `verifiableCredential` as an array. Used for the contract lookup and to scope the consent match — never for ownership. | | `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`; a trailing `/` is trimmed. | | `consent_api_host` | `string` | No | — | Overrides the HTTP `Host` header on consent-manager calls. Needed when `consent_api_url` points at an in-cluster service whose gateway route is host-scoped to the public ingress name. | | `consent_api_timeout` | `int` | No | `5000` | Per-call timeout in ms. Range 1–60000. | diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 0c09ede..21d2b93 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -275,10 +275,10 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke // put a fail-open seam in the middle of a fail-closed design, reachable by // nothing more than a briefly unreachable consent-manager or a revoked token. resolveParties := ownerresolver.Parties{} - consumerDID := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim) - if consumerDID == "" { - log.Printf("[consent-filter] ResponseFilter: no consuming participant in the token claims (path %q) for request %s", cfg.ConsumerClaim, key) - return failOutcome(cfg, failAlwaysClosed, "no consuming participant identified", key, nil) + consumerDID, claimErr := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim) + if claimErr != nil { + log.Printf("[consent-filter] ResponseFilter: could not read the consuming participant for request %s: %v", key, claimErr) + return failOutcome(cfg, failAlwaysClosed, "no consuming participant identified: "+claimErr.Error(), key, nil) } consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(phaseCtx, consumerDID) if sdErr != nil { @@ -660,7 +660,7 @@ func claimKeysToDecode(cfg *Config) []string { if cfg.ConsumerClaim == "" { return keys } - root := strings.SplitN(cfg.ConsumerClaim, claimPathSeparator, 2)[0] + root := claimPathRoot(cfg.ConsumerClaim) for _, k := range keys { if k == root { return keys @@ -669,30 +669,129 @@ func claimKeysToDecode(cfg *Config) []string { return append(keys, root) } -// claimPathSeparator separates the segments of a dotted claim path. -const claimPathSeparator = "." +// Claim-path syntax. A path is dot-separated segments, each optionally followed +// by bracketed array indices, e.g. "verifiableCredential[0].issuer". +const ( + // claimPathSeparator separates the segments of a dotted claim path. + claimPathSeparator = "." + + // claimIndexOpen and claimIndexClose bracket an explicit array index. + claimIndexOpen = "[" + claimIndexClose = "]" + + // firstElementIndex is the element used when a segment resolves to an array + // and the path names no index. + firstElementIndex = 0 +) + +// errClaimPathUnset signals that no consumer claim path is configured, as +// distinct from a configured path that did not resolve. Both deny, but only one +// is a configuration mistake worth reporting as such. +var errClaimPathUnset = errors.New("consumer_claim is not configured") // 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, which the caller treats as a failure to identify -// the exchange - the fail policy then applies. -func consumerFromClaims(claims map[string]interface{}, path string) string { - if len(claims) == 0 || path == "" { - return "" +// (e.g. "verifiableCredential.issuer"). +// +// A Verifiable Presentation commonly carries "verifiableCredential" as a JSON +// ARRAY, so a walk that only ever descends into objects fails on an ordinary +// token — silently, returning "" with no indication of which segment gave up. +// Two forms of array traversal are therefore supported: an explicit index +// ("verifiableCredential[0].issuer"), and an implicit first element when a bare +// segment lands on an array. +// +// The error names the segment that failed, so a mistyped path is diagnosable +// rather than appearing as a consumer that simply is not there. +func consumerFromClaims(claims map[string]interface{}, path string) (string, error) { + if path == "" { + return "", errClaimPathUnset + } + if len(claims) == 0 { + return "", errors.New("no claims decoded from the token") } + var current interface{} = claims for _, segment := range strings.Split(path, claimPathSeparator) { - node, ok := current.(map[string]interface{}) - if !ok { - return "" + name, indices, err := parseClaimSegment(segment) + if err != nil { + return "", err + } + if name != "" { + node, ok := descendIntoObject(current) + if !ok { + return "", fmt.Errorf("claim path %q: %q is not an object", path, segment) + } + current, ok = node[name] + if !ok { + return "", fmt.Errorf("claim path %q: no claim %q", path, name) + } + } + for _, index := range indices { + array, ok := current.([]interface{}) + if !ok { + return "", fmt.Errorf("claim path %q: %q is not an array", path, name) + } + if index >= len(array) { + return "", fmt.Errorf("claim path %q: index %d is out of range (%d element(s))", path, index, len(array)) + } + current = array[index] } - current, ok = node[segment] - if !ok { - return "" + } + + if value, ok := current.(string); ok && value != "" { + return value, nil + } + return "", fmt.Errorf("claim path %q did not resolve to a non-empty string", path) +} + +// descendIntoObject returns node as an object, stepping into the first element +// of an array first. A Verifiable Presentation's "verifiableCredential" is +// routinely an array of one, and requiring an explicit "[0]" for that common +// shape would make the default path wrong for most real tokens. +func descendIntoObject(node interface{}) (map[string]interface{}, bool) { + if array, ok := node.([]interface{}); ok { + if len(array) == 0 { + return nil, false + } + node = array[firstElementIndex] + } + object, ok := node.(map[string]interface{}) + return object, ok +} + +// parseClaimSegment splits one path segment into its claim name and any explicit +// array indices, e.g. "verifiableCredential[0]" -> ("verifiableCredential", [0]). +func parseClaimSegment(segment string) (name string, indices []int, err error) { + name, rest, found := strings.Cut(segment, claimIndexOpen) + if !found { + return segment, nil, nil + } + for rest != "" { + digits, remainder, closed := strings.Cut(rest, claimIndexClose) + if !closed { + return "", nil, fmt.Errorf("claim path segment %q: unterminated %q", segment, claimIndexOpen) + } + index, convErr := strconv.Atoi(digits) + if convErr != nil || index < 0 { + return "", nil, fmt.Errorf("claim path segment %q: %q is not an array index", segment, digits) } + indices = append(indices, index) + if remainder == "" { + break + } + if !strings.HasPrefix(remainder, claimIndexOpen) { + return "", nil, fmt.Errorf("claim path segment %q: unexpected %q after an index", segment, remainder) + } + rest = strings.TrimPrefix(remainder, claimIndexOpen) } - if s, ok := current.(string); ok { - return s + return name, indices, nil +} + +// claimPathRoot returns the first claim name in a dotted path, without any array +// index, so the request phase knows which top-level claim to decode. +func claimPathRoot(path string) string { + root := strings.SplitN(path, claimPathSeparator, 2)[0] + if name, _, found := strings.Cut(root, claimIndexOpen); found { + return name } - return "" + return root } diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 6cc0944..236572b 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -983,3 +983,122 @@ func TestRecordAudit_RecordsOutcomeWhenNoOwnerReached(t *testing.T) { defer mu.Unlock() assert.Equal(t, 1, records, "a failure before any owner was reached must still be recorded") } + +// TestConsumerFromClaims covers the claim-path walk, including the array shapes +// a real Verifiable Presentation uses. An object-only walk returned "" on an +// ordinary VP token, which fed straight into the fail-closed party seam and made +// every such request deny for no visible reason. +func TestConsumerFromClaims(t *testing.T) { + const issuer = "did:key:zIssuer" + + objectClaims := map[string]interface{}{ + "verifiableCredential": map[string]interface{}{"issuer": issuer}, + } + arrayClaims := map[string]interface{}{ + "verifiableCredential": []interface{}{ + map[string]interface{}{"issuer": issuer}, + map[string]interface{}{"issuer": "did:key:zOther"}, + }, + } + + tests := []struct { + name string + claims map[string]interface{} + path string + want string + wantErr bool + errSubstr string + }{ + {name: "object shape", claims: objectClaims, path: "verifiableCredential.issuer", want: issuer}, + {name: "array shape traverses the first element", claims: arrayClaims, path: "verifiableCredential.issuer", want: issuer}, + {name: "explicit index", claims: arrayClaims, path: "verifiableCredential[0].issuer", want: issuer}, + {name: "explicit non-zero index", claims: arrayClaims, path: "verifiableCredential[1].issuer", want: "did:key:zOther"}, + {name: "single segment", claims: map[string]interface{}{"iss": issuer}, path: "iss", want: issuer}, + { + name: "nested arrays", + claims: map[string]interface{}{"a": []interface{}{[]interface{}{map[string]interface{}{"b": issuer}}}}, + path: "a[0][0].b", + want: issuer, + }, + { + name: "unset path is reported as unconfigured", + claims: objectClaims, path: "", + wantErr: true, errSubstr: "not configured", + }, + { + name: "no claims at all", + claims: nil, path: "verifiableCredential.issuer", + wantErr: true, errSubstr: "no claims decoded", + }, + { + name: "missing claim names the segment", + claims: objectClaims, path: "verifiableCredential.subject", + wantErr: true, errSubstr: `no claim "subject"`, + }, + { + name: "index out of range", + claims: arrayClaims, path: "verifiableCredential[9].issuer", + wantErr: true, errSubstr: "out of range", + }, + { + name: "non-string leaf", + claims: map[string]interface{}{"iss": float64(42)}, path: "iss", + wantErr: true, errSubstr: "non-empty string", + }, + { + name: "empty-string leaf", + claims: map[string]interface{}{"iss": ""}, path: "iss", + wantErr: true, errSubstr: "non-empty string", + }, + { + name: "descending into a scalar", + claims: map[string]interface{}{"iss": issuer}, path: "iss.nested", + wantErr: true, errSubstr: "is not an object", + }, + { + name: "empty array", + claims: map[string]interface{}{"vc": []interface{}{}}, path: "vc.issuer", + wantErr: true, errSubstr: "is not an object", + }, + { + name: "unterminated index", + claims: arrayClaims, path: "verifiableCredential[0.issuer", + wantErr: true, errSubstr: "unterminated", + }, + { + name: "non-numeric index", + claims: arrayClaims, path: "verifiableCredential[first].issuer", + wantErr: true, errSubstr: "not an array index", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := consumerFromClaims(tt.claims, tt.path) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errSubstr) + assert.Empty(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestClaimPathRoot verifies the top-level claim the request phase must decode +// is found even when the path starts with an array index. +func TestClaimPathRoot(t *testing.T) { + tests := []struct{ path, want string }{ + {"verifiableCredential.issuer", "verifiableCredential"}, + {"verifiableCredential[0].issuer", "verifiableCredential"}, + {"iss", "iss"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + assert.Equal(t, tt.want, claimPathRoot(tt.path)) + }) + } +} From 51802f18e45f3e0fcb2338ded124a72467291a9c Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:11:32 +0200 Subject: [PATCH 15/41] refactor(logging): leveled, redacted, rate-limited logging (M-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roughly twenty `log.Printf` calls with a hand-written "[consent-filter]" prefix were spread across plugin/ and audit/. Three consequences, all on the request path: - The runner ships its own zap logger and configures its level from the runner's environment, so these lines bypassed it entirely — an operator could not raise or lower the plugin's verbosity, or suppress it, at all. - Messages carried subject and participant DIDs and upstream error bodies: personal data on stdout, with no retention policy, which is exactly what the OTLP audit path was built to avoid. - Nothing was rate-limited, so a broken consent-manager produced one line per request. A new `internal/logging` package addresses all three and every call site is converted: - `Debugf`/`Infof`/`Warnf`/`Errorf` delegate to the runner's logger, so the runner's level configuration applies and each line has an appropriate level instead of everything being an undifferentiated print. - `Redact` turns an identifier into a stable 8-hex-character fingerprint — enough to correlate lines about the same subject while debugging, not enough to be a handle on the subject. The consent client's "no participant registered" error now carries a fingerprint rather than the DID, since that error is both logged and used as an audit reason. - `Sanitize` collapses control characters and bounds length, so an HTML or JSON error page from a dependency cannot be splatted across the log. The audit package's `SanitizeReason` now delegates to it rather than duplicating it. - `WarnfEvery`/`ErrorfEvery` collapse a repeated failure to one line per 10s per key and report how many occurrences were suppressed, so the rate limiting is never silent about itself. The key set is bounded so it cannot grow into a leak. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 ++ internal/audit/audit.go | 34 ++---- internal/audit/audit_test.go | 33 ----- internal/consent/client.go | 7 +- internal/logging/logging.go | 204 +++++++++++++++++++++++++++++++ internal/logging/logging_test.go | 102 ++++++++++++++++ internal/plugin/config.go | 4 +- internal/plugin/consent.go | 38 +++--- internal/plugin/context.go | 8 +- 9 files changed, 351 insertions(+), 86 deletions(-) create mode 100644 internal/logging/logging.go create mode 100644 internal/logging/logging_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 427ecfb..60097f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,9 @@ consent-plugin/ │ ├── audit/ │ │ ├── audit.go # OTLP/HTTP access-decision audit exporter │ │ └── audit_test.go # Unit tests for the audit exporter +│ ├── logging/ +│ │ ├── logging.go # Leveled logging front end: redaction, sanitisation, rate limiting +│ │ └── logging_test.go # Unit tests for the logging front end │ ├── jwt/ │ │ ├── extractor.go # JWT extraction and claim decoding (no verification) │ │ └── extractor_test.go # Unit tests for JWT extraction @@ -114,6 +117,10 @@ make docker-build the two-call check (`/users/identifier/search` + `/consents/participants/{id}`). A consent counts only if it is granted **to the named consumer** (and covers the purpose/resource when known). +- `internal/logging/logging.go` — Logging front end over the runner's zap logger: + `Redact` fingerprints identifiers, `Sanitize` strips error bodies, and the + `*Every` variants rate-limit a repeated failure to one line per interval. + Nothing in the plugin calls `log.Printf` directly. - `internal/audit/audit.go` — Access-decision audit emitter: one OTLP/HTTP log record per decision to the OTel Collector (`service.name=consent-access-audit` for routing). Async, batched, best-effort; gated by `audit_enabled` + diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 48e7dc1..5026a8d 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -30,9 +30,9 @@ package audit import ( "bytes" + "consent-plugin/internal/logging" "context" "encoding/json" - "log" "net/http" "sort" "strconv" @@ -40,7 +40,6 @@ import ( "sync" "sync/atomic" "time" - "unicode" ) // DefaultServiceName is the resource service.name stamped on audit records when @@ -233,7 +232,7 @@ func (e *Emitter) Emit(ev Event) { case e.queue <- ev: default: if n := e.dropped.Add(1); n%droppedLogEvery == 1 { - log.Printf("[consent-filter] audit queue full, dropping event (total dropped %d)", n) + logging.Warnf("audit queue full, dropping event (total dropped %d)", n) } } } @@ -258,16 +257,9 @@ func Dropped() uint64 { return total } -// maxReasonLength bounds an exported reason. Reasons are short explanations; a -// long one means an upstream error body has been spliced into it. -const maxReasonLength = 200 - // droppedLogEvery rate-limits the queue-full log line. const droppedLogEvery = 100 -// reasonRedaction replaces the tail of an over-long reason. -const reasonRedaction = "...(redacted)" - // SanitizeReason makes a decision reason safe to export: control characters // (including the newlines of an HTML or JSON error page) are collapsed to // spaces, and the result is truncated. @@ -276,19 +268,7 @@ const reasonRedaction = "...(redacted)" // consent-manager's response body — which can carry identifiers or other // personal data. Exporting it verbatim would push exactly the data the audit // pipeline exists to keep controlled into the audit sink. -func SanitizeReason(reason string) string { - cleaned := strings.Map(func(r rune) rune { - if unicode.IsControl(r) { - return ' ' - } - return r - }, reason) - cleaned = strings.Join(strings.Fields(cleaned), " ") - if len(cleaned) > maxReasonLength { - return cleaned[:maxReasonLength-len(reasonRedaction)] + reasonRedaction - } - return cleaned -} +func SanitizeReason(reason string) string { return logging.Sanitize(reason) } // Shutdown stops the background worker after flushing everything still queued. // Intended for clean teardown and tests; the plugin runner is long-lived and @@ -341,14 +321,14 @@ func (e *Emitter) run() { func (e *Emitter) export(batch []Event) { body, err := json.Marshal(e.buildPayload(batch)) if err != nil { - log.Printf("[consent-filter] audit: failed to marshal %d event(s): %v", len(batch), err) + logging.Errorf("audit: failed to marshal %d event(s): %s", len(batch), logging.Sanitize(err.Error())) return } ctx, cancel := context.WithTimeout(context.Background(), e.client.Timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, bytes.NewReader(body)) if err != nil { - log.Printf("[consent-filter] audit: failed to build request: %v", err) + logging.Errorf("audit: failed to build the export request: %s", logging.Sanitize(err.Error())) return } req.Header.Set("Content-Type", "application/json") @@ -357,12 +337,12 @@ func (e *Emitter) export(batch []Event) { } resp, err := e.client.Do(req) if err != nil { - log.Printf("[consent-filter] audit: export to %s failed: %v", e.endpoint, err) + logging.ErrorfEvery("audit-export", "audit: export to %s failed: %s", e.endpoint, logging.Sanitize(err.Error())) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusMultipleChoices { - log.Printf("[consent-filter] audit: export to %s returned HTTP %d", e.endpoint, resp.StatusCode) + logging.ErrorfEvery("audit-export-status", "audit: export to %s returned HTTP %d", e.endpoint, resp.StatusCode) } } diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 8be4478..d7c2155 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -22,7 +22,6 @@ import ( "io" "net/http" "net/http/httptest" - "strings" "sync" "testing" "time" @@ -185,38 +184,6 @@ func TestEmitSendsConfiguredHeaders(t *testing.T) { } } -// TestSanitizeReason verifies an upstream error body spliced into a reason -// cannot reach the audit sink verbatim: control characters are collapsed and the -// result is bounded. -func TestSanitizeReason(t *testing.T) { - tests := []struct { - name string - reason string - want string - }{ - {name: "empty", reason: "", want: ""}, - {name: "plain reason is untouched", reason: "no granted consent", want: "no granted consent"}, - { - name: "newlines and tabs collapse to single spaces", - reason: "consent check error:\n\t{\"error\":\"boom\"}\r\n", - want: `consent check error: {"error":"boom"}`, - }, - { - name: "an over-long reason is truncated and marked", - reason: "x" + strings.Repeat("y", 500), - want: "x" + strings.Repeat("y", maxReasonLength-len(reasonRedaction)-1) + reasonRedaction, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := SanitizeReason(tt.reason) - assert.Equal(t, tt.want, got) - assert.LessOrEqual(t, len(got), maxReasonLength) - }) - } -} - // TestEmitSanitizesReason verifies the sanitisation happens on the way out, so // no call site can bypass it. func TestEmitSanitizesReason(t *testing.T) { diff --git a/internal/consent/client.go b/internal/consent/client.go index ca43bfd..64351c7 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -19,6 +19,7 @@ package consent import ( "bytes" + "consent-plugin/internal/logging" "context" "crypto/sha256" "encoding/hex" @@ -492,8 +493,12 @@ func (c *Client) ParticipantSelfDescriptionByDID(ctx context.Context, did string // notRegistered builds the "no such participant" error, wrapping the sentinel so // callers can tell a misconfigured DID from an unreachable registry. +// +// The DID is fingerprinted rather than embedded: this error is both logged to +// stdout and used as an audit reason, and a participant DID is an identifier +// that belongs in the audit record's own field, not in free text. func notRegistered(did string) error { - return fmt.Errorf("%w: no participant registered for did %q", ErrParticipantNotRegistered, did) + return fmt.Errorf("%w: no participant registered for did %s", ErrParticipantNotRegistered, logging.Redact(did)) } // lookupParticipantSD fetches the participant registry and returns the diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..49a3436 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,204 @@ +/* + * 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 logging is the plugin's logging front end. +// +// It exists for three reasons, each of which was a problem with calling the +// standard library's log.Printf directly: +// +// - Levels. The go-plugin-runner ships its own zap logger and configures its +// level from the runner's environment. Lines written with log.Printf bypass +// it entirely, so an operator could not raise or lower the plugin's verbosity +// at all. Everything here goes through the runner's logger. +// +// - Personal data. Log lines on the request path carry subject DIDs and +// upstream error bodies, i.e. personal data on stdout with no retention +// policy — exactly what the OTLP audit path exists to avoid. Redact turns an +// identifier into a stable fingerprint that still correlates across lines, +// and Sanitize strips an error body down to something safe to print. +// +// - Volume. A broken consent-manager produced one line per request. The +// Every variants collapse a repeated failure to one line per interval and +// report how many were suppressed. +package logging + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" + "unicode" + + runnerlog "github.com/apache/apisix-go-plugin-runner/pkg/log" +) + +// logPrefix marks every line as coming from this plugin. +const logPrefix = "[consent-filter] " + +// Debugf logs at debug level. +func Debugf(template string, args ...interface{}) { + runnerlog.Debugf(logPrefix+template, args...) +} + +// Infof logs at info level. +func Infof(template string, args ...interface{}) { + runnerlog.Infof(logPrefix+template, args...) +} + +// Warnf logs at warn level. +func Warnf(template string, args ...interface{}) { + runnerlog.Warnf(logPrefix+template, args...) +} + +// Errorf logs at error level. +func Errorf(template string, args ...interface{}) { + runnerlog.Errorf(logPrefix+template, args...) +} + +// --- rate limiting ----------------------------------------------------------- + +// suppressionInterval is how long one key stays silenced after it has logged. +// A dependency that fails for every request should cost one line per interval, +// not one line per request. +const suppressionInterval = 10 * time.Second + +type suppressionState struct { + lastLogged time.Time + suppressed uint64 +} + +var ( + suppressionMu sync.Mutex + suppressionByKey = map[string]*suppressionState{} + suppressionKeyCap = 1024 +) + +// allow reports whether key may log now, and how many lines it suppressed since +// it last did. +func allow(key string) (bool, uint64) { + suppressionMu.Lock() + defer suppressionMu.Unlock() + + state := suppressionByKey[key] + if state == nil { + // The key set is bounded: keys are compile-time constants in normal use, + // but a cap means a caller passing a variable key cannot grow the map. + if len(suppressionByKey) >= suppressionKeyCap { + return true, 0 + } + state = &suppressionState{} + suppressionByKey[key] = state + } + + now := time.Now() + if !state.lastLogged.IsZero() && now.Sub(state.lastLogged) < suppressionInterval { + state.suppressed++ + return false, 0 + } + suppressed := state.suppressed + state.suppressed = 0 + state.lastLogged = now + return true, suppressed +} + +// WarnfEvery logs at warn level at most once per suppressionInterval for the +// given key, noting how many occurrences were suppressed in between. +func WarnfEvery(key, template string, args ...interface{}) { + if ok, suppressed := allow(key); ok { + Warnf(template+suppressedSuffix(suppressed), args...) + } +} + +// ErrorfEvery logs at error level at most once per suppressionInterval for the +// given key, noting how many occurrences were suppressed in between. +func ErrorfEvery(key, template string, args ...interface{}) { + if ok, suppressed := allow(key); ok { + Errorf(template+suppressedSuffix(suppressed), args...) + } +} + +// suppressedSuffix renders the count of lines that were swallowed since this key +// last logged, so the rate limiting is never silent about itself. +func suppressedSuffix(suppressed uint64) string { + if suppressed == 0 { + return "" + } + return fmt.Sprintf(" (%d further occurrence(s) suppressed)", suppressed) +} + +// ResetSuppression clears the rate-limiter state. For tests. +func ResetSuppression() { + suppressionMu.Lock() + defer suppressionMu.Unlock() + suppressionByKey = map[string]*suppressionState{} +} + +// --- redaction --------------------------------------------------------------- + +// fingerprintLength is how many hex characters of the digest identify a value. +// Eight is enough to correlate lines about the same subject within a log without +// being a usable handle on the subject themselves. +const fingerprintLength = 8 + +// redactedPrefix marks a value as a fingerprint rather than an identifier. +const redactedPrefix = "id:" + +// Redact turns an identifier (a subject DID, a participant DID) into a stable, +// non-reversible fingerprint. +// +// The identifier itself belongs in the audit record, which is exported to a +// controlled sink with a retention policy — not in stdout logs, which have +// neither. The fingerprint is stable, so lines about the same subject can still +// be correlated while debugging. +func Redact(identifier string) string { + if identifier == "" { + return "" + } + sum := sha256.Sum256([]byte(identifier)) + return redactedPrefix + hex.EncodeToString(sum[:])[:fingerprintLength] +} + +// --- sanitisation ------------------------------------------------------------ + +// maxSanitizedLength bounds a sanitised message. Longer than this and an error +// body has been spliced into it. +const maxSanitizedLength = 200 + +// sanitizedRedaction replaces the tail of an over-long message. +const sanitizedRedaction = "...(redacted)" + +// Sanitize makes an error message safe to print or export: control characters +// (including the newlines of an HTML or JSON error page) collapse to single +// spaces, and the result is truncated. +// +// Messages built by wrapping dependency errors embed the dependency's response +// body, which can carry identifiers or other personal data. +func Sanitize(message string) string { + cleaned := strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, message) + cleaned = strings.Join(strings.Fields(cleaned), " ") + if len(cleaned) > maxSanitizedLength { + return cleaned[:maxSanitizedLength-len(sanitizedRedaction)] + sanitizedRedaction + } + return cleaned +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..779d714 --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,102 @@ +/* + * 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 logging + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSanitize verifies an error body spliced into a message cannot be printed +// or exported verbatim: control characters collapse and the result is bounded. +func TestSanitize(t *testing.T) { + tests := []struct { + name string + message string + want string + }{ + {name: "empty", message: "", want: ""}, + {name: "a plain message is untouched", message: "no granted consent", want: "no granted consent"}, + { + name: "newlines and tabs collapse to single spaces", + message: "consent check error:\n\t{\"error\":\"boom\"}\r\n", + want: `consent check error: {"error":"boom"}`, + }, + { + name: "an over-long message is truncated and marked", + message: "x" + strings.Repeat("y", 500), + want: "x" + strings.Repeat("y", maxSanitizedLength-len(sanitizedRedaction)-1) + sanitizedRedaction, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.message) + assert.Equal(t, tt.want, got) + assert.LessOrEqual(t, len(got), maxSanitizedLength) + }) + } +} + +// TestRedact verifies an identifier becomes a stable, short, non-reversible +// fingerprint — enough to correlate lines about the same subject, not enough to +// put a subject DID in stdout. +func TestRedact(t *testing.T) { + const did = "did:key:zAliceSomeVeryLongIdentifier" + + assert.Empty(t, Redact(""), "an empty identifier stays empty") + + redacted := Redact(did) + assert.Equal(t, redacted, Redact(did), "the fingerprint must be stable") + assert.NotContains(t, redacted, did) + assert.NotContains(t, redacted, "Alice") + assert.True(t, strings.HasPrefix(redacted, redactedPrefix), "a fingerprint must be recognisable as one") + assert.Len(t, redacted, len(redactedPrefix)+fingerprintLength) + assert.NotEqual(t, redacted, Redact("did:key:zBob"), "different identifiers must differ") +} + +// TestRateLimiting verifies a repeated failure costs one line per interval and +// reports how many it swallowed. A broken consent-manager previously emitted one +// line per request. +func TestRateLimiting(t *testing.T) { + ResetSuppression() + t.Cleanup(ResetSuppression) + + const key = "test-key" + + ok, suppressed := allow(key) + assert.True(t, ok, "the first occurrence must log") + assert.Zero(t, suppressed) + + for i := 0; i < 5; i++ { + ok, _ = allow(key) + assert.False(t, ok, "occurrences within the interval must be suppressed") + } + + // A different key is limited independently. + ok, _ = allow("other-key") + assert.True(t, ok, "each key has its own budget") +} + +// TestSuppressedSuffix verifies the rate limiting is never silent about itself. +func TestSuppressedSuffix(t *testing.T) { + assert.Empty(t, suppressedSuffix(0)) + assert.Contains(t, suppressedSuffix(7), "7 further occurrence(s) suppressed") +} diff --git a/internal/plugin/config.go b/internal/plugin/config.go index e2e4458..aa8828d 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -20,10 +20,10 @@ package plugin import ( + "consent-plugin/internal/logging" "encoding/json" "errors" "fmt" - "log" "net/url" "os" "strings" @@ -458,7 +458,7 @@ func ParseConfig(in []byte) (*Config, error) { } if conf.IsFailOpen() { - log.Printf("[consent-filter] WARNING: fail_open is enabled for %s — a consent-manager or resolver outage will RELEASE personal data instead of denying it", + logging.Warnf("fail_open is enabled for %s — a consent-manager or resolver outage will RELEASE personal data instead of denying it", conf.ConsentAPIURL) } diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 21d2b93..f68af5b 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -23,11 +23,11 @@ import ( "consent-plugin/internal/audit" "consent-plugin/internal/consent" "consent-plugin/internal/jwt" + "consent-plugin/internal/logging" "consent-plugin/internal/ownerresolver" "context" "errors" "fmt" - "log" "net/http" "strconv" "strings" @@ -103,7 +103,7 @@ func (c *ConsentFilter) ParseConf(in []byte) (interface{}, error) { func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r pkgHTTP.Request) { cfg, ok := conf.(*Config) if !ok { - log.Printf("[consent-filter] RequestFilter: invalid config type, skipping request %d", r.ID()) + logging.Errorf("RequestFilter: invalid config type, skipping request %d", r.ID()) return } @@ -121,13 +121,13 @@ func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r if jwtHeaderValue != "" { token, err := jwt.ExtractToken(jwtHeaderValue) if err != nil { - log.Printf("[consent-filter] RequestFilter: failed to extract JWT from header %q for request %d: %v", - cfg.JWTHeaderName, r.ID(), err) + logging.WarnfEvery("jwt-extract", "RequestFilter: failed to extract JWT from header %q: %s", + cfg.JWTHeaderName, logging.Sanitize(err.Error())) } else { 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) + logging.WarnfEvery("jwt-decode", "RequestFilter: failed to decode JWT claims: %s", + logging.Sanitize(err.Error())) } else { reqCtx.JWTClaims = claims } @@ -138,8 +138,8 @@ func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r // the runner's per-RPC ID() (which differs between pre-req and post-resp). key, ok := correlationKey(r) if !ok { - log.Printf("[consent-filter] RequestFilter: could not read %q for request %d; consent context not stored", - nginxRequestIDVar, r.ID()) + logging.ErrorfEvery("no-request-id-req", "RequestFilter: could not read %q; consent context not stored", + nginxRequestIDVar) return } @@ -202,7 +202,7 @@ type checkedOwner struct { func (c *ConsentFilter) ResponseFilter(conf interface{}, w pkgHTTP.Response) { cfg, ok := conf.(*Config) if !ok { - log.Printf("[consent-filter] ResponseFilter: invalid config type, skipping request %d", w.ID()) + logging.Errorf("ResponseFilter: invalid config type, skipping request %d", w.ID()) return } @@ -221,7 +221,7 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom // Correlate with the request phase via the stable Nginx $request_id. key, ok := correlationKey(w) if !ok { - log.Printf("[consent-filter] ResponseFilter: could not read %q for request %d; cannot verify consent", nginxRequestIDVar, w.ID()) + logging.ErrorfEvery("no-request-id-resp", "ResponseFilter: could not read %q; cannot verify consent", nginxRequestIDVar) return failOutcome(cfg, failAlwaysClosed, "no request correlation id", "", nil) } @@ -231,7 +231,7 @@ func (c *ConsentFilter) evaluate(cfg *Config, w pkgHTTP.Response) responseOutcom // The request phase did not capture context for this request; the // consent decision cannot be made, so honor the fail policy instead // of silently passing the response through. - log.Printf("[consent-filter] ResponseFilter: no request context found for request %s; cannot verify consent", key) + logging.WarnfEvery("no-request-context", "ResponseFilter: no request context found for request %s; cannot verify consent", key) return failOutcome(cfg, failAlwaysClosed, "no request context", key, nil) } @@ -255,7 +255,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke body, err := w.ReadBody() if err != nil { - log.Printf("[consent-filter] ResponseFilter: could not read upstream body for request %s: %v", key, err) + logging.ErrorfEvery("read-body", "ResponseFilter: could not read the upstream body for request %s: %s", key, logging.Sanitize(err.Error())) return failOutcome(cfg, failByPolicy, "read upstream body: "+err.Error(), key, nil) } @@ -277,12 +277,12 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke resolveParties := ownerresolver.Parties{} consumerDID, claimErr := consumerFromClaims(reqCtx.JWTClaims, cfg.ConsumerClaim) if claimErr != nil { - log.Printf("[consent-filter] ResponseFilter: could not read the consuming participant for request %s: %v", key, claimErr) + logging.WarnfEvery("consumer-claim", "ResponseFilter: could not read the consuming participant for request %s: %s", key, logging.Sanitize(claimErr.Error())) return failOutcome(cfg, failAlwaysClosed, "no consuming participant identified: "+claimErr.Error(), key, nil) } consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(phaseCtx, consumerDID) if sdErr != nil { - log.Printf("[consent-filter] ResponseFilter: could not map the consumer to a participant for request %s: %v", key, sdErr) + logging.WarnfEvery("consumer-lookup", "ResponseFilter: could not map the consumer to a participant for request %s: %s", key, logging.Sanitize(sdErr.Error())) return failOutcome(cfg, failModeForError(sdErr), "consumer participant lookup failed: "+sdErr.Error(), key, nil) } // The consumer also scopes the consent match itself: a consent names the one @@ -292,7 +292,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke providerSD, sdErr := consentClient.ProviderSelfDescription(phaseCtx) if sdErr != nil { - log.Printf("[consent-filter] ResponseFilter: could not determine the provider self-description for request %s: %v", key, sdErr) + logging.ErrorfEvery("provider-sd", "ResponseFilter: could not determine the provider self-description for request %s: %s", key, logging.Sanitize(sdErr.Error())) return failOutcome(cfg, failModeForError(sdErr), "provider self-description lookup failed: "+sdErr.Error(), key, nil) } resolveParties.Provider = providerSD @@ -305,7 +305,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke ContentType: contentType, }, resolveParties, body) if err != nil { - log.Printf("[consent-filter] ResponseFilter: owner resolver error for request %s: %v", key, err) + logging.ErrorfEvery("resolver-error", "ResponseFilter: owner resolver error for request %s: %s", key, logging.Sanitize(err.Error())) return failOutcome(cfg, failByPolicy, "owner resolver error: "+err.Error(), key, nil) } @@ -322,7 +322,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke return failOutcome(cfg, failAlwaysClosed, err.Error(), key, nil) } if len(claims) > cfg.MaxOwnersPerResponse { - log.Printf("[consent-filter] ResponseFilter: %d distinct data owners for request %s exceeds max_owners_per_response=%d; denying", + logging.WarnfEvery("owner-cap", "ResponseFilter: %d distinct data owners for request %s exceeds max_owners_per_response=%d; denying", len(claims), key, cfg.MaxOwnersPerResponse) return failOutcome(cfg, failAlwaysClosed, fmt.Sprintf("response resolves to %d data owners, above max_owners_per_response=%d", len(claims), cfg.MaxOwnersPerResponse), @@ -466,7 +466,7 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo if errors.Is(result.err, context.Canceled) && ctx.Err() == nil { continue } - log.Printf("[consent-filter] ResponseFilter: consent check error for request %s: %v", key, result.err) + logging.ErrorfEvery("consent-check", "ResponseFilter: consent check error for request %s: %s", key, logging.Sanitize(result.err.Error())) req := result.request outcome := failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) outcome.checked = checked @@ -633,7 +633,7 @@ func denyResponse(w pkgHTTP.Response, cfg *Config) { w.WriteHeader(cfg.DenyStatusCode) if _, err := w.Write(body); err != nil { - log.Printf("[consent-filter] ResponseFilter: failed to write deny body for request %d: %v", w.ID(), err) + logging.Errorf("ResponseFilter: failed to write the deny body for request %d: %s", w.ID(), logging.Sanitize(err.Error())) } } diff --git a/internal/plugin/context.go b/internal/plugin/context.go index 59daccf..d87f244 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -18,8 +18,8 @@ package plugin import ( + "consent-plugin/internal/logging" "fmt" - "log" "sync" "time" ) @@ -109,7 +109,7 @@ func startContextJanitor() { defer ticker.Stop() for range ticker.C { if n := sweepRequestContexts(time.Now()); n > 0 { - log.Printf("[consent-filter] request-context store: evicted %d expired entr(ies), %d remaining", + logging.Warnf("request-context store: evicted %d expired entr(ies), %d remaining", n, RequestContextStoreSize()) } } @@ -143,7 +143,7 @@ func StoreRequestContext(requestKey string, ctx *RequestContext) { // requestContextMu. func evictForSpaceLocked(now time.Time) { if n := sweepLocked(now); n > 0 { - log.Printf("[consent-filter] request-context store full (%d), evicted %d expired entr(ies)", MaxRequestContexts, n) + logging.WarnfEvery("context-store-full", "request-context store full (%d), evicted %d expired entr(ies)", MaxRequestContexts, n) return } oldestKey, oldestAt := "", time.Time{} @@ -155,7 +155,7 @@ func evictForSpaceLocked(now time.Time) { if oldestKey != "" { delete(requestContextStore, oldestKey) contextsEvicted++ - log.Printf("[consent-filter] request-context store full (%d) with no expired entries, evicted the oldest", MaxRequestContexts) + logging.ErrorfEvery("context-store-overflow", "request-context store full (%d) with no expired entries, evicted the oldest", MaxRequestContexts) } } From 36e3f7b40e0006fdc081ef53dddece865b19a9b9 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:13:03 +0200 Subject: [PATCH 16/41] fix(dev): make `docker compose up` actually work (M-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented dev workflow could not start: - `docker-compose.yaml` mounted `./apisix-config.yaml`, which was not in the repository. Docker creates a *directory* at a missing bind-mount path, so APISIX started with a directory where its config should be and failed to parse it. - Both services bind-mounted `/tmp/runner.sock` — a socket file that does not exist at compose time, so Docker created a directory there too and the runner could not bind. The socket has to live inside a shared *directory*; a named `runner-socket` volume mounted at `/opt/runner` now provides one. - `version: "3.8"` is obsolete under Compose v2. Since README advertised `docker compose up --build` as the dev workflow, this was the first thing a new contributor hit. The stack is now self-contained and exercisable rather than merely startable: - `dev/apisix-config.yaml` wires `ext-plugin.path_for_test` at the shared socket and enables both external-plugin phases. - `mock` (WireMock, stubs in `dev/mocks/`) stands in for the consent-manager, the OwnerResolver and the participant token service — including the consumer scoping, so editing `consents.json` flips the gate's answer. - `upstream` is an echo service so the resolver has a real payload to be asked about, and `otel-collector` receives the access-decision audit log with the routing that `service.name=consent-access-audit` exists for. - Credentials reach the runner through env vars, mirroring how the Kubernetes deployment sources them from a Secret. The README section now gives the route-creation and request commands end to end. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +++- README.md | 37 +++++++++++++++++++++- dev/apisix-config.yaml | 41 ++++++++++++++++++++++++ dev/mocks/consents.json | 17 ++++++++++ dev/mocks/identifier-search.json | 9 ++++++ dev/mocks/owner-resolver.json | 14 +++++++++ dev/mocks/participants-me.json | 9 ++++++ dev/mocks/participants.json | 11 +++++++ dev/mocks/token-service.json | 9 ++++++ dev/otel-collector.yaml | 26 +++++++++++++++ docker-compose.yaml | 54 +++++++++++++++++++++++++++----- 11 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 dev/apisix-config.yaml create mode 100644 dev/mocks/consents.json create mode 100644 dev/mocks/identifier-search.json create mode 100644 dev/mocks/owner-resolver.json create mode 100644 dev/mocks/participants-me.json create mode 100644 dev/mocks/participants.json create mode 100644 dev/mocks/token-service.json create mode 100644 dev/otel-collector.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 60097f7..38adfbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,11 @@ consent-plugin/ │ │ └── extractor_test.go # Unit tests for JWT extraction │ └── integration/ │ └── integration_test.go # End-to-end plugin lifecycle tests -└── docker-compose.yaml # Local dev with APISIX + plugin runner +├── dev/ +│ ├── apisix-config.yaml # APISIX config for the local stack (ext-plugin wiring) +│ ├── otel-collector.yaml # Collector config receiving the audit log +│ └── mocks/ # WireMock stubs: consent-manager, OwnerResolver, token service +└── docker-compose.yaml # Local dev with APISIX + plugin runner + mocks ``` ## Build & Test diff --git a/README.md b/README.md index 6852728..d7e9fde 100644 --- a/README.md +++ b/README.md @@ -194,11 +194,46 @@ make lint # golangci-lint docker compose up --build ``` +The stack is self-contained — APISIX + etcd, the plugin runner, a mock +consent-manager / OwnerResolver / token service, an echo upstream, and an OTel +Collector for the audit log: + | Service | Description | Ports | |---------|-------------|-------| | `etcd` | APISIX configuration store | `2379` | | `apisix` | APISIX gateway | `9080` (HTTP), `9180` (Admin API) | -| `plugin-runner` | consent-filter plugin runner | — (Unix socket) | +| `plugin-runner` | consent-filter plugin runner | — (unix socket, shared volume) | +| `mock` | consent-manager + OwnerResolver + token service stubs (`dev/mocks/`) | `8081` | +| `upstream` | echo service standing in for the personal-data API | — | +| `otel-collector` | receives the access-decision audit log | `4318` | + +Then create the gated route: + +```bash +PLUGIN_CONF='{"consent_api_url":"http://mock:8080","owner_resolver_url":"http://mock:8080/resolve","token_service_url":"http://mock:8080/internal/tokens","consent_key":"dev-consent-key","service":"dev-profiles","fail_open":false}' + +jq -n --arg conf "$PLUGIN_CONF" '{ + uri: "/*", + upstream: { type: "roundrobin", nodes: { "upstream:8080": 1 } }, + plugins: { + "ext-plugin-pre-req": { conf: [ { name: "consent-filter", value: $conf } ] }, + "ext-plugin-post-resp": { conf: [ { name: "consent-filter", value: $conf } ] } + } +}' | curl -s -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \ + -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" -H "Content-Type: application/json" -d @- + +# The token names the consumer the mock's consent was granted to, so this passes. +TOKEN_PAYLOAD=$(printf '{"verifiableCredential":{"issuer":"did:key:zDevConsumer"}}' | base64 -w0 | tr '+/' '-_' | tr -d '=') +curl -i http://127.0.0.1:9080/profile -H "Authorization: Bearer e30.${TOKEN_PAYLOAD}.nosig" +``` + +Change the `consumer` in `dev/mocks/consents.json` (or the `status` to +`revoked`) and restart the `mock` service to watch the same request be denied. +The `otel-collector` logs show the audit record for each decision. + +> The socket is shared through a **directory** (the `runner-socket` volume), not +> by bind-mounting the socket file: Docker creates a directory at a bind-mount +> path that does not exist yet, and the runner then cannot bind. ## Project Structure diff --git a/dev/apisix-config.yaml b/dev/apisix-config.yaml new file mode 100644 index 0000000..4f159b9 --- /dev/null +++ b/dev/apisix-config.yaml @@ -0,0 +1,41 @@ +# APISIX configuration for the local development stack (docker-compose.yaml). +# +# The only part specific to this project is `ext-plugin.path_for_test`, which +# points APISIX at the unix socket the plugin-runner container binds. The two +# containers share it through the `runner-socket` volume: the socket must live in +# a shared DIRECTORY, because bind-mounting the socket file itself makes Docker +# create a directory at that path before either process can bind it. + +apisix: + node_listen: 9080 + enable_admin: true + proxy_mode: http + +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: + - name: admin + # Development only. Never reuse this key outside the local stack. + key: edd1c9f034335f136f87ad84b625c8f1 + role: admin + allow_admin: + - 0.0.0.0/0 + admin_listen: + port: 9180 + etcd: + host: + - "http://etcd:2379" + prefix: /apisix + timeout: 30 + +ext-plugin: + # The runner is started by its own container, not by APISIX, so APISIX only + # needs to know where to reach it. + path_for_test: /opt/runner/runner.sock + +plugins: + - ext-plugin-pre-req + - ext-plugin-post-resp diff --git a/dev/mocks/consents.json b/dev/mocks/consents.json new file mode 100644 index 0000000..7d534d4 --- /dev/null +++ b/dev/mocks/consents.json @@ -0,0 +1,17 @@ +{ + "//": "GET /consents/participants/{id}?receipt=true — the owner's consents. Granted TO dev-consumer, which is what makes the check pass; change the consumer here to watch the gate deny.", + "request": { "method": "GET", "urlPathPattern": "/v1/consents/participants/.*" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "consents": [ + { + "status": "granted", + "consumer": { "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" }, + "data": [{ "resource": "http://mock:8080/resources/personal-profile" }] + } + ] + } + } +} diff --git a/dev/mocks/identifier-search.json b/dev/mocks/identifier-search.json new file mode 100644 index 0000000..2383bce --- /dev/null +++ b/dev/mocks/identifier-search.json @@ -0,0 +1,9 @@ +{ + "//": "POST /users/identifier/search — resolves the data owner DID (sent as the user email) to the provider-scoped user identifier.", + "request": { "method": "POST", "url": "/v1/users/identifier/search" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "userIdentifier": "dev-user-identifier" } + } +} diff --git a/dev/mocks/owner-resolver.json b/dev/mocks/owner-resolver.json new file mode 100644 index 0000000..d64de99 --- /dev/null +++ b/dev/mocks/owner-resolver.json @@ -0,0 +1,14 @@ +{ + "//": "POST /resolve — the OwnerResolver. It answers from the DATA who the owner is; here it always reports the same owner so the stack has a working happy path.", + "request": { "method": "POST", "url": "/resolve" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "consentRequired": true, + "claims": [ + { "selector": { "type": "jsonpath", "value": "$.id" }, "ownerId": "did:key:zDevOwner" } + ] + } + } +} diff --git a/dev/mocks/participants-me.json b/dev/mocks/participants-me.json new file mode 100644 index 0000000..2c60670 --- /dev/null +++ b/dev/mocks/participants-me.json @@ -0,0 +1,9 @@ +{ + "//": "GET /participants/me — the provider self-description the identifier search is scoped by.", + "request": { "method": "GET", "url": "/v1/participants/me" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "selfDescriptionURL": "http://mock:8080/participants/dev-provider" } + } +} diff --git a/dev/mocks/participants.json b/dev/mocks/participants.json new file mode 100644 index 0000000..a8578ab --- /dev/null +++ b/dev/mocks/participants.json @@ -0,0 +1,11 @@ +{ + "//": "GET /participants — the registry that maps the consumer DID from the token to the self-description URL a contract names its parties by.", + "request": { "method": "GET", "url": "/v1/participants" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": [ + { "did": "did:key:zDevConsumer", "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" } + ] + } +} diff --git a/dev/mocks/token-service.json b/dev/mocks/token-service.json new file mode 100644 index 0000000..1997546 --- /dev/null +++ b/dev/mocks/token-service.json @@ -0,0 +1,9 @@ +{ + "//": "The participant-local OID4VP token service (the consent-facade's POST /internal/tokens). It mints the short-lived participant token the plugin uses for the consents lookup.", + "request": { "method": "POST", "url": "/internal/tokens" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "access_token": "dev-participant-token", "token_type": "Bearer", "expires_in": 3600 } + } +} diff --git a/dev/otel-collector.yaml b/dev/otel-collector.yaml new file mode 100644 index 0000000..a400c40 --- /dev/null +++ b/dev/otel-collector.yaml @@ -0,0 +1,26 @@ +# OpenTelemetry Collector configuration for the local development stack. +# +# The plugin exports one OTLP/HTTP log record per checked data owner, marked with +# resource service.name=consent-access-audit. This config shows the routing that +# marker exists for: audit records go to their own pipeline, which in a real +# deployment would be an append-only sink rather than the console. + +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: {} + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] diff --git a/docker-compose.yaml b/docker-compose.yaml index 6d651a7..4d3a06b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,10 +2,16 @@ # # Usage: # docker compose up --build +# # then create the gated route (see README, "Local Development with Docker Compose") # -# This starts APISIX with etcd and the Go plugin runner sidecar. - -version: "3.8" +# The stack is self-contained: APISIX and its etcd, the plugin runner, a mock +# consent-manager + OwnerResolver + token service, an upstream that returns +# personal data, and an OTel Collector to receive the access-decision audit log. +# +# Note the socket wiring. APISIX and the runner share a DIRECTORY (the +# runner-socket volume) and the socket is created inside it. Bind-mounting the +# socket file itself does not work: Docker creates a directory at that path +# before either process can bind, and the runner then fails to start. services: etcd: @@ -22,12 +28,13 @@ services: image: apache/apisix:3.8.0-debian depends_on: - etcd + - plugin-runner ports: - "9080:9080" # HTTP proxy port - "9180:9180" # Admin API port volumes: - - ./apisix-config.yaml:/usr/local/apisix/conf/config.yaml:ro - - /tmp/runner.sock:/tmp/runner.sock + - ./dev/apisix-config.yaml:/usr/local/apisix/conf/config.yaml:ro + - runner-socket:/opt/runner restart: on-failure plugin-runner: @@ -35,7 +42,40 @@ services: context: . dockerfile: Dockerfile volumes: - - /tmp/runner.sock:/tmp/runner.sock + - runner-socket:/opt/runner environment: - APISIX_LISTEN_ADDRESS: "unix:/tmp/runner.sock" + APISIX_LISTEN_ADDRESS: "unix:/opt/runner/runner.sock" + # Credentials the plugin reads from the environment rather than the route + # config, mirroring how the Kubernetes deployment sources them from a Secret. + CONSENT_KEY: "dev-consent-key" + CONSENT_TOKEN_SERVICE_URL: "http://mock:8080/internal/tokens" + CONSENT_AUDIT_OTLP_ENDPOINT: "http://otel-collector:4318" restart: on-failure + + # Mock consent-manager, OwnerResolver and participant token service. The + # stubs live in dev/mocks/ — edit consents.json to flip the gate's answer. + mock: + image: wiremock/wiremock:3.9.1 + command: ["--port", "8080", "--verbose"] + volumes: + - ./dev/mocks:/home/wiremock/mappings:ro + ports: + - "8081:8080" + + # The upstream whose responses are gated. It echoes the request back as JSON, + # which is enough for the OwnerResolver stub to be asked about a payload. + upstream: + image: mendhak/http-https-echo:34 + environment: + HTTP_PORT: "8080" + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.109.0 + command: ["--config=/etc/otel-collector.yaml"] + volumes: + - ./dev/otel-collector.yaml:/etc/otel-collector.yaml:ro + ports: + - "4318:4318" + +volumes: + runner-socket: From 0573e31e8721e498084b27166bed0324c9ed1874 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:13:39 +0200 Subject: [PATCH 17/41] ci: pin the scanners and let them fail the build (M-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `security-analysis.yml` ran both `govulncheck` and `gosec` with `continue-on-error: true`, so their findings were informational only and a known-vulnerable dependency merged cleanly. A security gate that cannot fail is a dashboard, not a gate — both now block the pipeline, and the SARIF upload still populates the Security tab either way. Versions were also unpinned. `gosec` was installed from `@latest` and `golangci-lint-action` used `version: latest`, which made CI non-reproducible (a scanner release could redden an untouched PR) and put an unpinned binary inside the build's trust boundary. Worse, the Gitea pipeline pinned golangci-lint to v2.1.6 while GitHub used `latest`, so the two CIs could disagree about whether the same commit lints. - `gosec` is pinned via a `GOSEC_VERSION` env var, and runs without `-no-fail`; a reviewed finding is suppressed at the call site with a `#nosec` comment carrying its reason, so the exception shows up in the diff. - golangci-lint is pinned to v2.13.1 in both `.github/workflows/style-guide.yml` and `.gitea/workflows/ci.yaml`, with a comment on each pointing at the other. - `go mod verify` runs before the vulnerability scan, checking the module cache against go.sum rather than trusting whatever was downloaded. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/ci.yaml | 3 ++- .github/workflows/security-analysis.yml | 34 ++++++++++++++++++++----- .github/workflows/style-guide.yml | 6 ++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 468433d..f11cd9e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -30,7 +30,8 @@ jobs: - name: Install golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v2.1.6 + # Must match .github/workflows/style-guide.yml. + version: v2.13.1 args: ./... - name: Run tests with coverage diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml index d249d32..9c2a0c0 100644 --- a/.github/workflows/security-analysis.yml +++ b/.github/workflows/security-analysis.yml @@ -1,10 +1,23 @@ name: Security Analysis -# Reusable source-level security scanning. Findings are uploaded as SARIF to -# the GitHub Security tab; scans never block the pipeline (continue-on-error). +# Reusable source-level security scanning. +# +# The scans BLOCK the pipeline. They previously ran with continue-on-error, so a +# known-vulnerable dependency merged cleanly and the findings were informational +# only — a security gate that cannot fail is a dashboard, not a gate. SARIF is +# still uploaded to the Security tab either way. +# +# Tool versions are pinned. `@latest` made CI non-reproducible (a scanner release +# could redden an untouched PR) and put an unpinned binary in the build's trust +# boundary. Keep GOSEC_VERSION and the golangci-lint version in .gitea/workflows/ +# and .github/workflows/style-guide.yml in step, so the two pipelines cannot +# disagree about whether the code passes. on: workflow_call: +env: + GOSEC_VERSION: v2.21.4 + jobs: govulncheck: runs-on: ubuntu-latest @@ -17,8 +30,12 @@ jobs: with: go-version-file: go.mod + # Verifies the module cache against go.sum before anything is built with it. + - name: Verify module checksums + run: go mod verify + - uses: golang/govulncheck-action@v1.0.4 - continue-on-error: true + id: govulncheck with: go-version-file: go.mod output-format: sarif @@ -40,11 +57,14 @@ jobs: with: go-version-file: go.mod + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@${{ env.GOSEC_VERSION }} + + # -no-fail is deliberately NOT passed: a new finding must fail the PR. + # Suppress a reviewed finding at the call site with a #nosec comment + # carrying the reason, so the exception is visible in the diff. - name: Run gosec - continue-on-error: true - run: | - go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec -fmt sarif -out gosec-results.sarif ./... + run: gosec -fmt sarif -out gosec-results.sarif ./... - uses: github/codeql-action/upload-sarif@v4 if: always() diff --git a/.github/workflows/style-guide.yml b/.github/workflows/style-guide.yml index 9ff0a9c..cfd9b73 100644 --- a/.github/workflows/style-guide.yml +++ b/.github/workflows/style-guide.yml @@ -1,6 +1,10 @@ name: Style Guide # Reusable lint check using the repository's .golangci.yml. +# +# The version is PINNED and must match .gitea/workflows/ci.yaml. With `latest` +# here and a pin there, the two pipelines could disagree about whether the same +# commit lints — and a golangci-lint release could redden an untouched PR. on: workflow_call: @@ -17,4 +21,4 @@ jobs: - uses: golangci/golangci-lint-action@v9 with: - version: latest + version: v2.13.1 From cc58f0c94d5d1839a7da51c197d42850a6eb9259 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:14:34 +0200 Subject: [PATCH 18/41] refactor: delete the dead field-filtering model (L-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `internal/consent/models.go` still described a `POST /check` API returning allow/deny/**filter** with a list of denied fields. That design was replaced by the coarse two-call gate; the client never calls such an endpoint and the plugin does no field-level filtering. The types were referenced only by their own tests, which flattered the coverage number, and their `json:` tags actively misled anyone reading the file for the wire format. Removed: `DecisionFilter`, `validDecisions`, `Decision.IsValid`, `ConsentResponse.Validate`, `ConsentResponse.DeniedFields`, `ConsentRequest.ResponseFields` and `ConsentRequest.Claims` (the last of which also meant the whole decoded token was being carried into a request body that is never sent anywhere). `ownerresolver.Claim.Selector` / `.Participant` and `Result.Scheme` were decoded and never read; they are gone too, with a comment saying the reply carries more than this and only what the plugin acts on is decoded — an unread field should not suggest the plugin considers something it does not. `ConsentRequest.Subject` is now documented for what it is: the data owner from the resolver, never the requestor. Co-Authored-By: Claude Opus 5 --- internal/consent/client_test.go | 24 ------------ internal/consent/models.go | 66 ++++++-------------------------- internal/ownerresolver/client.go | 30 ++++++++------- 3 files changed, 29 insertions(+), 91 deletions(-) diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index b38b32f..b5f9bd6 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -499,30 +499,6 @@ func TestCheckConsentContextCancellation(t *testing.T) { assert.Contains(t, err.Error(), "HTTP request failed") } -// TestDecisionIsValid verifies the Decision.IsValid method. -func TestDecisionIsValid(t *testing.T) { - assert.True(t, DecisionAllow.IsValid()) - assert.True(t, DecisionDeny.IsValid()) - assert.True(t, DecisionFilter.IsValid()) - assert.False(t, Decision("").IsValid()) - assert.False(t, Decision("maybe").IsValid()) -} - -// TestConsentResponseValidate verifies the Validate method on ConsentResponse. -func TestConsentResponseValidate(t *testing.T) { - require.NoError(t, (&ConsentResponse{Decision: DecisionAllow}).Validate()) - require.NoError(t, (&ConsentResponse{Decision: DecisionDeny, Reason: "no consent"}).Validate()) - - err := (&ConsentResponse{Decision: ""}).Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "decision field is empty") - - err = (&ConsentResponse{Decision: "block"}).Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "unrecognized decision") -} - -// TestTruncateBody verifies the body truncation helper. func TestTruncateBody(t *testing.T) { assert.Equal(t, "short", truncateBody([]byte("short"))) assert.Equal(t, "", truncateBody([]byte{})) diff --git a/internal/consent/models.go b/internal/consent/models.go index 6926da3..ee7cdb9 100644 --- a/internal/consent/models.go +++ b/internal/consent/models.go @@ -15,14 +15,13 @@ * 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. +// Package consent provides an HTTP client for the external consent-manager, +// which decides whether a data owner has granted the consuming participant +// consent to access their personal data. The verdict is coarse — allow or deny +// for the whole response — and is enforced by the plugin's response phase. package consent -import "fmt" - -// Decision represents the consent API's verdict on a request. +// Decision represents the consent-manager's verdict on one data owner. // It determines how the plugin handles the upstream response. type Decision string @@ -34,29 +33,13 @@ const ( // DecisionDeny indicates the response should be blocked entirely, // returning a configured error status and body to the client. DecisionDeny Decision = "deny" - - // DecisionFilter indicates the response should be modified by removing - // specific fields identified in the DeniedFields list. - DecisionFilter Decision = "filter" ) -// validDecisions is the set of recognized Decision values, used for validation. -var validDecisions = map[Decision]bool{ - DecisionAllow: true, - DecisionDeny: true, - DecisionFilter: true, -} - -// IsValid reports whether d is a recognized Decision value. -func (d Decision) IsValid() bool { - return validDecisions[d] -} - -// ConsentRequest represents the payload sent to the consent API's /check endpoint. -// It contains information about the original request and the response fields -// so the consent API can make an informed allow/deny/filter decision. +// ConsentRequest is one consent question: may this consumer be given this data +// owner's data, for this purpose and resource? type ConsentRequest struct { - // Subject is the identity of the requester, typically from the JWT "sub" claim. + // Subject is the DATA OWNER whose consent decides the access — resolved from + // the response payload by the OwnerResolver. It is never the requestor. Subject string `json:"subject"` // Resource is the request path being accessed (e.g., "/api/v1/users/123"). @@ -82,39 +65,14 @@ type ConsentRequest struct { // covers this purpose. Empty means the purpose is not known — the consumer // match still applies. Purpose string `json:"purpose,omitempty"` - - // Claims contains the forwarded JWT claims as key-value pairs. - Claims map[string]interface{} `json:"claims,omitempty"` - - // ResponseFields lists the top-level field names found in the upstream - // response body, enabling field-level consent decisions. - ResponseFields []string `json:"response_fields,omitempty"` } -// ConsentResponse represents the payload returned by the consent API's /check endpoint. -// It contains the consent decision and any additional information about which -// fields to remove or the reason for the decision. +// ConsentResponse is the verdict for one ConsentRequest. type ConsentResponse struct { - // Decision is the consent verdict: "allow", "deny", or "filter". + // Decision is the consent verdict: "allow" or "deny". Decision Decision `json:"decision"` - // DeniedFields lists the field names or dot-notation paths (e.g., "user.email") - // that should be removed from the response body when Decision is "filter". - DeniedFields []string `json:"denied_fields,omitempty"` - // Reason is a human-readable explanation for the consent decision, - // useful for logging and debugging. + // recorded in the audit log and useful for debugging. Reason string `json:"reason,omitempty"` } - -// Validate checks that the ConsentResponse contains a valid decision. -// Returns an error if the decision field is empty or unrecognized. -func (r *ConsentResponse) Validate() error { - if r.Decision == "" { - return fmt.Errorf("consent response validation: decision field is empty") - } - if !r.Decision.IsValid() { - return fmt.Errorf("consent response validation: unrecognized decision %q", r.Decision) - } - return nil -} diff --git a/internal/ownerresolver/client.go b/internal/ownerresolver/client.go index 4a38b54..bf6b32d 100644 --- a/internal/ownerresolver/client.go +++ b/internal/ownerresolver/client.go @@ -44,18 +44,19 @@ const ( 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. +// +// The resolver's reply carries more than this (a selector locating the claim in +// the payload, the participant, the scheme). Only the fields the plugin acts on +// are decoded; the rest is ignored, so an unread field cannot suggest the plugin +// considers something it does not. type Claim struct { - Selector Selector `json:"selector"` - OwnerID string `json:"ownerId"` - Participant string `json:"participant,omitempty"` - DataResource string `json:"dataResource,omitempty"` + // OwnerID is the data owner whose consent decides this claim. + OwnerID string `json:"ownerId"` + + // DataResource, when set, scopes the consent match to one resource. + DataResource string `json:"dataResource,omitempty"` + // Purpose names the processing purpose (or contract) governing this claim, // when the resolver could identify the contract from the parties. It scopes // the consent match: a granted consent counts only if it covers this purpose. @@ -65,9 +66,12 @@ type Claim struct { // Result is the OwnerResolver response. type Result struct { - ConsentRequired bool `json:"consentRequired"` - Scheme string `json:"scheme,omitempty"` - Claims []Claim `json:"claims"` + // ConsentRequired reports whether the payload needs a consent check at all. + ConsentRequired bool `json:"consentRequired"` + + // Claims are the ownership requirements found in the data. Every one must be + // satisfied for the response to be released. + Claims []Claim `json:"claims"` } type resourceDescriptor struct { From a7a18d4c88a28cf7e4a451b8b1eb399b7aa41130 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:14:58 +0200 Subject: [PATCH 19/41] docs: fix package docs that contradicted the behaviour (L-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `internal/plugin`'s package doc (duplicated across config.go and consent.go) still said the plugin "applies consent-based filtering for personal data", and RequestFilter's doc still claimed it "captures all request headers" — which it stopped doing when the header capture was removed. `internal/consent`'s package doc described deciding whether data is "allowed, denied, or filtered". None of that is true: the gate is coarse allow/deny, there is no field-level filtering, and no headers are retained. The plugin package doc now says so and says why the coarse choice is deliberate — a filter that removes fields silently misses the one it does not know about, while a coarse gate still covers an empty or non-JSON personal-data response. The consent package doc was corrected when the dead filtering model was deleted. Co-Authored-By: Claude Opus 5 --- internal/plugin/config.go | 2 -- internal/plugin/consent.go | 20 +++++++++++++------- internal/plugin/context.go | 5 ++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/plugin/config.go b/internal/plugin/config.go index aa8828d..c81f809 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -15,8 +15,6 @@ * 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 import ( diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index f68af5b..0aad963 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -15,8 +15,16 @@ * 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 implements the APISIX consent-filter plugin. +// +// The plugin gates a personal-data response on the consent of the DATA OWNER. +// The request phase captures the token's claims; the response phase asks the +// OwnerResolver who owns the payload and checks, per owner, that the consuming +// participant has a granted consent. The verdict is coarse — the whole response +// is allowed or replaced with a denial. Despite the plugin's registered name +// there is no field-level filtering or redaction: a gate that removes fields +// silently misses the one it does not know about, while a coarse gate still +// covers an empty or non-JSON personal-data response. package plugin import ( @@ -91,11 +99,9 @@ func (c *ConsentFilter) ParseConf(in []byte) (interface{}, error) { return ParseConfig(in) } -// RequestFilter intercepts incoming HTTP requests to capture request context -// (headers, JWT claims, path, method) for use during response filtering. -// It extracts the JWT from the configured header, decodes the requested claims, -// captures all request headers, and stores the context keyed by request ID -// for later retrieval in ResponseFilter. +// RequestFilter intercepts incoming HTTP requests to capture the context the +// response phase needs: the method, the path, and the claims decoded from the +// configured JWT header, stored under the request's correlation key. // // The JWT is decoded, NOT verified (see internal/jwt): the claims are used only // to name the consuming participant for the contract lookup, and the route MUST diff --git a/internal/plugin/context.go b/internal/plugin/context.go index d87f244..d84e4d5 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -24,9 +24,8 @@ import ( "time" ) -// RequestContext holds the captured request information that is needed -// during response filtering. It is stored during RequestFilter and -// retrieved during ResponseFilter. +// RequestContext holds the captured request information the response phase +// needs. It is stored during RequestFilter and retrieved during ResponseFilter. // // It deliberately holds no request headers. The only thing the response phase // needs from the request is the method, the path and the decoded claims; keeping From 7c50c72c8f61cce76a50a906a6cad721f9ec8a3e Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:16:00 +0200 Subject: [PATCH 20/41] build: harden the runtime image (L-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime image ran as **root** with no `USER`, on `alpine:3.19` (past end-of-support, so no security patches), with no `HEALTHCHECK` and no `.dockerignore` — meaning `COPY . .` pulled in `.git`, changing the build context digest on every commit and invalidating the cache even when no source file had changed. - A fixed non-root uid (10001) runs the binary. The runner binds a unix socket in a directory it is given and makes outbound HTTP calls; root only widens what a compromise of it reaches. The uid is fixed so a shared socket volume can be given predictable ownership — the compose stack hands the volume over with a one-shot `socket-init` service, since a named volume is created root-owned. - Base bumped to `alpine:3.22`. - `HEALTHCHECK` probes the listener: the runner speaks the ext-plugin protocol rather than HTTP, so a bound socket is the only meaningful signal that it is accepting RPCs. APISIX now waits for it via `service_healthy`. - `.dockerignore` excludes `.git`, CI config, docs and build artifacts. - `CGO_ENABLED=0` for a static binary, and `go mod verify` in the build stage so the dependencies are checked against go.sum before anything is compiled. Co-Authored-By: Claude Opus 5 --- .dockerignore | 16 ++++++++++++++++ Dockerfile | 29 +++++++++++++++++++++-------- docker-compose.yaml | 17 +++++++++++++++-- 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d73a937 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Keep the build context small and stable. Without this, `COPY . .` pulls in +# .git, so every commit changes the context digest and invalidates the build +# cache even when no source file changed. +.git +.github +.gitea +.golangci.yml +.dockerignore +Dockerfile +docker-compose.yaml +dev +hack +coverage.out +go-runner +*.md +LICENSE diff --git a/Dockerfile b/Dockerfile index 0b2cdd9..d658b0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,43 @@ # Multi-stage build for the consent-plugin APISIX go-plugin-runner. -# Stage 1: Compile the Go binary. -# Stage 2: Copy into a minimal runtime image. +# Stage 1: compile the Go binary. +# Stage 2: copy it into a minimal runtime image. # --- Build stage --- FROM golang:1.26-alpine AS builder -RUN apk add --no-cache git - WORKDIR /build # Cache dependency downloads by copying go.mod/go.sum first. COPY go.mod go.sum ./ -RUN go mod download +RUN go mod download && go mod verify -# Copy source code and build the binary. +# Copy source code and build the binary. .dockerignore keeps .git and build +# artifacts out, so an unrelated commit does not invalidate this layer. COPY . . -RUN go build -trimpath -ldflags="-s -w" -o go-runner . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o go-runner . # --- Runtime stage --- -FROM alpine:3.19 +FROM alpine:3.22 RUN apk add --no-cache ca-certificates +# The runner has no reason to be root: it binds a unix socket in a directory it +# is given and makes outbound HTTP calls. Running as root only widens what a +# compromise of it reaches. The uid is fixed so a shared socket volume can be +# given predictable ownership. +RUN addgroup -g 10001 -S runner && adduser -u 10001 -S -G runner runner + WORKDIR /app COPY --from=builder /build/go-runner /app/go-runner +USER 10001:10001 + +# The runner speaks the ext-plugin protocol, not HTTP, so there is nothing to +# probe but the listener itself: a bound socket means it is accepting RPCs. A TCP +# listen address is left to the orchestrator to probe. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD sh -c 'case "$APISIX_LISTEN_ADDRESS" in unix:*) test -S "${APISIX_LISTEN_ADDRESS#unix:}" ;; *) exit 0 ;; esac' + # The plugin runner binary is the entrypoint. ENTRYPOINT ["/app/go-runner"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d3a06b..60f6812 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,8 +27,10 @@ services: apisix: image: apache/apisix:3.8.0-debian depends_on: - - etcd - - plugin-runner + etcd: + condition: service_started + plugin-runner: + condition: service_healthy ports: - "9080:9080" # HTTP proxy port - "9180:9180" # Admin API port @@ -37,10 +39,21 @@ services: - runner-socket:/opt/runner restart: on-failure + # A named volume is created root-owned, and the runner image runs as uid 10001, + # so hand the socket directory over before the runner starts. + socket-init: + image: alpine:3.22 + command: ["chown", "10001:10001", "/opt/runner"] + volumes: + - runner-socket:/opt/runner + plugin-runner: build: context: . dockerfile: Dockerfile + depends_on: + socket-init: + condition: service_completed_successfully volumes: - runner-socket:/opt/runner environment: From 18354dff4f6a8de82bb1045957e76677d7f2bcfb Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:16:37 +0200 Subject: [PATCH 21/41] build(deps): refresh direct dependencies and add Dependabot (L-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct dependencies had drifted years behind with nothing configured to notice: - `github.com/stretchr/testify` 1.8.4 -> 1.12.1 - `github.com/api7/ext-plugin-proto` v0.6.0 -> v0.6.1 `go mod tidy` also drops `github.com/davecgh/go-spew` and `github.com/pmezard/go-difflib` from the graph and moves yaml to `go.yaml.in/yaml/v3`. Tests pass under `-race` and the linter is clean on the new versions. `.github/dependabot.yml` turns future drift into a pull request rather than a review finding, covering Go modules, GitHub Actions and Docker base images. The go-plugin-runner is deliberately excluded from the batch group: it is pinned at v0.5.0 and drags an old transitive tree (zap 1.17, flatbuffers 2.0.0), so updating it is a decision to take deliberately — and a plugin whose runner is unmaintained is a strategic risk worth being reminded of. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 40 ++++++++++++++++++++++++++++++++++++++++ go.mod | 8 +++----- go.sum | 16 ++++++---------- 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..67c526a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +# Dependency updates. +# +# The direct dependencies had drifted years behind (testify 1.8.4 while 1.12.1 +# was current) with nothing to notice. This makes the drift a pull request +# instead of a review finding. +# +# The go-plugin-runner is deliberately grouped on its own: it is pinned at v0.5.0 +# and its transitive tree (zap 1.17, flatbuffers 2.0.0) is old, so an update to +# it is a decision to make deliberately rather than merge with the batch. A +# plugin whose runner is unmaintained is a strategic risk worth surfacing +# regularly. +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + go-dependencies: + patterns: + - "*" + exclude-patterns: + - "github.com/apache/apisix-go-plugin-runner" + labels: + - dependencies + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + labels: + - dependencies diff --git a/go.mod b/go.mod index 54a0170..802360d 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,15 @@ go 1.26 require ( github.com/apache/apisix-go-plugin-runner v0.5.0 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.12.1 ) require ( github.com/ReneKroon/ttlcache/v2 v2.4.0 // indirect - github.com/api7/ext-plugin-proto v0.6.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/api7/ext-plugin-proto v0.6.1 // indirect github.com/google/flatbuffers v2.0.0+incompatible // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.7.0 // indirect go.uber.org/zap v1.17.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect ) diff --git a/go.sum b/go.sum index 83992a3..0b18c2a 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,9 @@ github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/apisix-go-plugin-runner v0.5.0 h1:kg2FpLWdbrzGXwS6wc6etwZa7tpd//R9q1HMUwUEd34= github.com/apache/apisix-go-plugin-runner v0.5.0/go.mod h1:KEdzfoWik+m5JTOnYoV1X/QWat1vmcchzCjhyy68XZE= -github.com/api7/ext-plugin-proto v0.6.0 h1:xmgcKwWRiM9EpBIs1wYJ7Ife/YnLl4IL2NEy4417g60= github.com/api7/ext-plugin-proto v0.6.0/go.mod h1:8dbdAgCESeqwZ0IXirbjLbshEntmdrAX3uet+LW3jVU= +github.com/api7/ext-plugin-proto v0.6.1 h1:eQN0oHacL97ezVGWVmsRigt+ClcpgjipUq0rmW8BG4g= +github.com/api7/ext-plugin-proto v0.6.1/go.mod h1:8dbdAgCESeqwZ0IXirbjLbshEntmdrAX3uet+LW3jVU= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -76,7 +77,6 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -208,10 +208,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -245,7 +243,6 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -290,8 +287,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/thediveo/enumflag v0.10.1/go.mod h1:KyVhQUPzreSw85oJi2uSjFM0ODLKXBH0rPod7zc2pmI= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -327,6 +324,8 @@ go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95a go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -663,7 +662,6 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= @@ -680,8 +678,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 9077f660a3a533915aa921be34fa6f797fdab6d8 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:17:35 +0200 Subject: [PATCH 22/41] fix(plugin): stop marking every allowed response as modified (L-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `evaluateWithResolver` called `w.Header()` to read the upstream's Content-Type for the resolve request. The runner materialises its header map on that first call and `HasChange()` then returns true — so merely *looking* at a header sent every gated response back to APISIX down the "this response was modified" path, carrying an empty header diff. Probably benign, entirely untested, and needless. The Content-Type now comes from the Nginx `$upstream_http_content_type` variable, which has no such side effect. The header is consulted only when the variable is unavailable, as a degraded fallback rather than the norm. The response mock grows a `headerReads` counter so this is observable, and `TestResponseFilter_AllowDoesNotTouchHeaders` asserts an allowed response never materialises the header map. Both content-type sources are covered. Co-Authored-By: Claude Opus 5 --- internal/integration/integration_test.go | 5 +- internal/plugin/consent.go | 29 +++++++-- internal/plugin/consent_test.go | 81 +++++++++++++++++++++--- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index fe92fcd..0861ea2 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -147,8 +147,11 @@ func (r *mockResponse) ID() uint32 { return r.id } func (r *mockResponse) StatusCode() int { return http.StatusOK } func (r *mockResponse) Header() pkgHTTP.Header { return r.header } func (r *mockResponse) Var(name string) ([]byte, error) { - if name == "request_id" { + switch name { + case "request_id": return []byte(integrationReqKey(r.id)), nil + case "upstream_http_content_type": + return []byte(responseContentTypeJSON), nil } return nil, nil } diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 0aad963..783a661 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -56,6 +56,16 @@ const pluginName = "consent-filter" // the other. const nginxRequestIDVar = "request_id" +// nginxUpstreamContentTypeVar is the Nginx variable ($upstream_http_content_type) +// holding the Content-Type the upstream answered with. +// +// It is read in preference to Response.Header() because that method lazily +// materialises the runner's header map, and the runner then reports +// HasChange() == true for the response — so merely LOOKING at a header sent +// every allowed response back to APISIX down the "this response was modified" +// path, carrying an empty header diff. Reading a variable has no such effect. +const nginxUpstreamContentTypeVar = "upstream_http_content_type" + // varReader is the subset of the runner's Request/Response interfaces that // exposes Nginx variables. Both pkgHTTP.Request and pkgHTTP.Response satisfy it. type varReader interface { @@ -152,6 +162,20 @@ func (c *ConsentFilter) RequestFilter(conf interface{}, w http.ResponseWriter, r StoreRequestContext(key, reqCtx) } +// responseContentType reports the Content-Type of the upstream response, +// preferring the Nginx variable so an allowed response is not marked as +// modified (see nginxUpstreamContentTypeVar). The header is only consulted when +// the variable is unavailable, which is a degraded case rather than the norm. +func responseContentType(w pkgHTTP.Response) string { + if value, err := w.Var(nginxUpstreamContentTypeVar); err == nil && len(value) > 0 { + return string(value) + } + if header := w.Header(); header != nil { + return header.Get("Content-Type") + } + return "" +} + // decisionAllow and decisionDeny are the audit-facing labels for the decision. const ( decisionAllow = "allow" @@ -265,10 +289,7 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke return failOutcome(cfg, failByPolicy, "read upstream body: "+err.Error(), key, nil) } - contentType := "" - if h := w.Header(); h != nil { - contentType = h.Get("Content-Type") - } + contentType := responseContentType(w) // Parties are for CONTRACT identification only - never for ownership. The // token names the consumer by DID, while contracts name their parties by diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 236572b..839b8aa 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -109,13 +109,20 @@ func (h *mockHeader) View() http.Header { return h.headers } // mockResponse implements pkgHTTP.Response for testing. type mockResponse struct { - id uint32 - statusCode int - header *mockHeader - body []byte - readErr error + id uint32 + statusCode int + header *mockHeader + body []byte + readErr error + // headerReads counts Header() calls. The runner materialises its header map + // on the first one and then reports the response as modified, so an allowed + // response must not touch it. + headerReads int writtenBody []byte writtenStatus int + // suppressContentTypeVar makes Var() report no upstream Content-Type, to + // exercise the header fallback. + suppressContentTypeVar bool } // newMockResponse builds a JSON upstream response carrying body. @@ -125,15 +132,24 @@ func newMockResponse(id uint32, body []byte) *mockResponse { return &mockResponse{id: id, header: h, body: body} } -func (r *mockResponse) ID() uint32 { return r.id } -func (r *mockResponse) StatusCode() int { return r.statusCode } -func (r *mockResponse) Header() pkgHTTP.Header { return r.header } +func (r *mockResponse) ID() uint32 { return r.id } +func (r *mockResponse) StatusCode() int { return r.statusCode } +func (r *mockResponse) Header() pkgHTTP.Header { + r.headerReads++ + return r.header +} // Var returns the Nginx request id ($request_id) derived from the mock's id so // correlationKey resolves to the same key the tests store under. func (r *mockResponse) Var(name string) ([]byte, error) { - if name == nginxRequestIDVar { + switch name { + case nginxRequestIDVar: return []byte(testReqKey(r.id)), nil + case nginxUpstreamContentTypeVar: + if r.suppressContentTypeVar { + return nil, nil + } + return []byte(responseContentTypeJSON), nil } return nil, nil } @@ -1102,3 +1118,50 @@ func TestClaimPathRoot(t *testing.T) { }) } } + +// TestResponseFilter_AllowDoesNotTouchHeaders verifies an allowed response never +// calls Header(). The runner materialises its header map on the first call and +// then reports HasChange() == true, so merely reading the Content-Type sent +// every gated response back to APISIX down the "this response was modified" +// path with an empty header diff. +func TestResponseFilter_AllowDoesNotTouchHeaders(t *testing.T) { + clearContextStore() + server := newConsentManager(t, "uid-1", []string{"granted"}) + defer server.Close() + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() + + const id = uint32(230) + storeRequest(id) + resp := newMockResponse(id, []byte(`{"id":"x"}`)) + + (&ConsentFilter{}).ResponseFilter(newTestConfig(server.URL, resolver.URL+"/resolve"), resp) + + assert.Equal(t, 0, resp.writtenStatus, "the response should have been allowed") + assert.Zero(t, resp.headerReads, "an allowed response must not materialise the header map") +} + +// TestResponseContentType covers both sources: the Nginx variable, and the +// header fallback for a deployment where the variable is unavailable. +func TestResponseContentType(t *testing.T) { + t.Run("prefers the nginx variable", func(t *testing.T) { + resp := newMockResponse(240, nil) + assert.Equal(t, responseContentTypeJSON, responseContentType(resp)) + assert.Zero(t, resp.headerReads, "the variable must be enough") + }) + + t.Run("falls back to the header", func(t *testing.T) { + resp := newMockResponse(241, nil) + resp.suppressContentTypeVar = true + resp.header.Set("Content-Type", "application/ld+json") + assert.Equal(t, "application/ld+json", responseContentType(resp)) + assert.Positive(t, resp.headerReads) + }) + + t.Run("reports nothing when neither source has it", func(t *testing.T) { + resp := newMockResponse(242, nil) + resp.suppressContentTypeVar = true + resp.header.Del("Content-Type") + assert.Empty(t, responseContentType(resp)) + }) +} From 2c191140d891d83a5388bed054f30a1087f4d59d Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:19:53 +0200 Subject: [PATCH 23/41] feat(metrics): expose the gate's operational signals (L-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component that can deny production traffic had no counters at all. Nothing said how many requests were allowed, denied or failed open, how long the consent-manager was taking, how large the request-context store had grown, or how many audit records had been dropped. Logs were the only signal, and they are unstructured and rate-limited — a misconfigured route denying everything looked exactly like a quiet one. `internal/metrics` exposes, in the Prometheus text format: - `consent_decisions_total{decision,fail_mode}` — crucially labelled by fail mode, so "denied because there is no consent" and "denied because the consent-manager was down" are separable. They mean opposite things, and one of them is a page. - `consent_dependency_calls_total{dependency,outcome}` and `consent_dependency_duration_seconds{dependency}` — a histogram whose buckets straddle the default 5s per-call timeout, so a dependency drifting toward it is visible before it starts failing. - `consent_request_context_store_size` and `consent_request_contexts_evicted_total` — the H-3 leak, made observable: the size returns to zero when idle, and a rising floor is the leak. - `consent_audit_events_dropped_total` — an attacker who can generate load can suppress the record of their own access, so the loss must be alertable. The exporter is hand-rolled rather than pulling in a Prometheus client, for the same reason the OTLP encoder is: this is a sidecar-adjacent plugin whose dependency tree is part of its risk surface. Output is ordered deterministically so a scrape diff reflects real change. `main.go` serves `/metrics` only when `CONSENT_METRICS_ADDRESS` is set — the runner is otherwise reached only over its unix socket, so opening a TCP port is the deployment's decision — and a failure there logs rather than taking the gate down. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 + README.md | 11 ++ internal/audit/audit.go | 8 + internal/metrics/metrics.go | 254 +++++++++++++++++++++++++++++++ internal/metrics/metrics_test.go | 114 ++++++++++++++ internal/plugin/consent.go | 28 +++- internal/plugin/context.go | 8 + main.go | 43 ++++++ 8 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 internal/metrics/metrics.go create mode 100644 internal/metrics/metrics_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 38adfbd..be4cf8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,9 @@ consent-plugin/ │ ├── audit/ │ │ ├── audit.go # OTLP/HTTP access-decision audit exporter │ │ └── audit_test.go # Unit tests for the audit exporter +│ ├── metrics/ +│ │ ├── metrics.go # Prometheus text-format exporter (decisions, latency, gauges) +│ │ └── metrics_test.go # Unit tests for the exporter │ ├── logging/ │ │ ├── logging.go # Leveled logging front end: redaction, sanitisation, rate limiting │ │ └── logging_test.go # Unit tests for the logging front end @@ -121,6 +124,9 @@ make docker-build the two-call check (`/users/identifier/search` + `/consents/participants/{id}`). A consent counts only if it is granted **to the named consumer** (and covers the purpose/resource when known). +- `internal/metrics/metrics.go` — Hand-rolled Prometheus exporter (no client + dependency, like the OTLP encoder). Served from `main.go` on + `CONSENT_METRICS_ADDRESS` when set. - `internal/logging/logging.go` — Logging front end over the runner's zap logger: `Redact` fingerprints identifiers, `Sanitize` strips error bodies, and the `*Every` variants rate-limit a repeated failure to one line per interval. diff --git a/README.md b/README.md index d7e9fde..a39b73d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,17 @@ route config; a value in the config always wins. The plugin runner inherits thes from the APISIX container, which sources them from a Kubernetes Secret — so secrets need not be stored as plaintext in the route config (etcd). +**Metrics.** Set the `CONSENT_METRICS_ADDRESS` environment variable on the +plugin runner (e.g. `:9091`) to expose Prometheus metrics on `/metrics`: +`consent_decisions_total{decision,fail_mode}` (a deny caused by an outage is +labelled apart from one caused by consent), +`consent_dependency_calls_total{dependency,outcome}`, +`consent_dependency_duration_seconds{dependency}`, +`consent_request_context_store_size`, `consent_request_contexts_evicted_total` +and `consent_audit_events_dropped_total`. Metrics are off unless the variable is +set — the runner is otherwise reached only over its unix socket, so opening a +TCP port is the deployment's decision. + **Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per **checked data owner** (so the log answers whose consent was consulted and what each said, not merely whether the response was released; a request that failed before any owner was reached is recorded once as itself) to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. The queue is flushed on `SIGTERM`/`SIGINT`, so a redeploy does not discard the last flush interval of decisions. Reasons are sanitised before export (control characters collapsed, length bounded) so an upstream error body cannot reach the audit sink verbatim. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. ## APISIX Route Configuration Example diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 5026a8d..236f38c 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -31,6 +31,7 @@ package audit import ( "bytes" "consent-plugin/internal/logging" + "consent-plugin/internal/metrics" "context" "encoding/json" "net/http" @@ -153,6 +154,13 @@ var ( emitters = map[string]*Emitter{} ) +func init() { + // An attacker who can generate load can suppress the record of their own + // access by filling the queue, so the loss must be alertable, not merely + // logged every hundredth event. + metrics.RegisterGauge(metrics.AuditDroppedGauge, func() float64 { return float64(Dropped()) }) +} + // Get returns a shared Emitter for cfg, creating (and starting) one on first use. // Emitters are cached by the full configuration (see Config.key), so all routes // exporting to the same Collector with the same settings share a single diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..35dacb3 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,254 @@ +/* + * 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 metrics exposes the consent gate's operational signals in the +// Prometheus text format. +// +// A component that can deny production traffic had no counters at all: nothing +// said how many requests were allowed, denied or failed open, how long the +// consent-manager was taking, how large the request-context store had grown, or +// how many audit records had been dropped. Logs were the only signal, and they +// are unstructured and rate-limited. Operationally that is flying blind — a +// misconfigured route that denies everything looks exactly like a quiet one. +// +// The exporter is hand-rolled rather than pulling in a Prometheus client, for +// the same reason the OTLP encoder is: this is a sidecar-adjacent plugin whose +// dependency tree is part of its risk surface, and the text format is small. +package metrics + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Metric names. The consent_ prefix keeps them together in a shared registry. +const ( + decisionsMetric = "consent_decisions_total" + dependencyCallsMetric = "consent_dependency_calls_total" + dependencyLatency = "consent_dependency_duration_seconds" + contextStoreSizeMetric = "consent_request_context_store_size" + contextEvictedMetric = "consent_request_contexts_evicted_total" + auditDroppedMetric = "consent_audit_events_dropped_total" +) + +// Dependency names used as the "dependency" label. +const ( + DependencyConsentManager = "consent_manager" + DependencyOwnerResolver = "owner_resolver" +) + +// Call outcomes used as the "outcome" label. +const ( + OutcomeSuccess = "success" + OutcomeError = "error" +) + +// latencyBuckets are the histogram's upper bounds in seconds. They straddle the +// default per-call timeout (5s) so a dependency drifting toward it is visible +// before it starts failing. +var latencyBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10} + +var ( + mu sync.Mutex + + // decisions counts enforced decisions by decision and by the fail mode that + // produced them, so "denied because no consent" and "denied because the + // consent-manager was down" are distinguishable — they mean opposite things + // operationally. + decisions = map[labelPair]uint64{} + + // dependencyCalls counts outbound calls by dependency and outcome. + dependencyCalls = map[labelPair]uint64{} + + // latency holds one histogram per dependency. + latency = map[string]*histogram{} + + // gauges are read at scrape time from whoever owns the number, so this + // package never has to be told when a store's size changes. + gaugeMu sync.Mutex + gauges = map[string]func() float64{} +) + +// labelPair is a two-label metric key. +type labelPair struct{ first, second string } + +// histogram is a cumulative-bucket histogram. +type histogram struct { + counts []uint64 + sum float64 + total uint64 +} + +// observe records one value. +func (h *histogram) observe(value float64) { + for i, bound := range latencyBuckets { + if value <= bound { + h.counts[i]++ + } + } + h.sum += value + h.total++ +} + +// RecordDecision counts one enforced access decision. failMode names why the +// decision could not be made normally ("" for an ordinary consent verdict), so +// a deny caused by an outage is not confused with a deny caused by consent. +func RecordDecision(decision, failMode string) { + if failMode == "" { + failMode = "none" + } + mu.Lock() + defer mu.Unlock() + decisions[labelPair{decision, failMode}]++ +} + +// RecordDependencyCall records one outbound call to a dependency: its outcome +// and how long it took. +func RecordDependencyCall(dependency, outcome string, duration time.Duration) { + mu.Lock() + defer mu.Unlock() + dependencyCalls[labelPair{dependency, outcome}]++ + h := latency[dependency] + if h == nil { + h = &histogram{counts: make([]uint64, len(latencyBuckets))} + latency[dependency] = h + } + h.observe(duration.Seconds()) +} + +// RegisterGauge publishes a value read at scrape time. The owner of the number +// keeps owning it; this package only asks for it. +func RegisterGauge(name string, read func() float64) { + gaugeMu.Lock() + defer gaugeMu.Unlock() + gauges[name] = read +} + +// Handler serves the metrics in the Prometheus text exposition format. +func Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + if _, err := w.Write([]byte(render())); err != nil { + // Nothing useful to do: the scraper went away mid-write. + return + } + }) +} + +// render produces the full exposition payload. +func render() string { + var out strings.Builder + + mu.Lock() + writeCounter(&out, decisionsMetric, "Access decisions enforced, by decision and fail mode.", + "decision", "fail_mode", decisions) + writeCounter(&out, dependencyCallsMetric, "Outbound calls to a dependency, by outcome.", + "dependency", "outcome", dependencyCalls) + + fmt.Fprintf(&out, "# HELP %s Duration of outbound dependency calls in seconds.\n", dependencyLatency) + fmt.Fprintf(&out, "# TYPE %s histogram\n", dependencyLatency) + for _, dependency := range sortedMapKeys(latency) { + h := latency[dependency] + cumulative := uint64(0) + for i, bound := range latencyBuckets { + cumulative = h.counts[i] + fmt.Fprintf(&out, "%s_bucket{dependency=%q,le=%q} %d\n", + dependencyLatency, dependency, strconv.FormatFloat(bound, 'g', -1, 64), cumulative) + } + fmt.Fprintf(&out, "%s_bucket{dependency=%q,le=\"+Inf\"} %d\n", dependencyLatency, dependency, h.total) + fmt.Fprintf(&out, "%s_sum{dependency=%q} %s\n", dependencyLatency, dependency, strconv.FormatFloat(h.sum, 'g', -1, 64)) + fmt.Fprintf(&out, "%s_count{dependency=%q} %d\n", dependencyLatency, dependency, h.total) + } + mu.Unlock() + + gaugeMu.Lock() + names := make([]string, 0, len(gauges)) + for name := range gauges { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(&out, "# TYPE %s gauge\n%s %s\n", name, name, + strconv.FormatFloat(gauges[name](), 'g', -1, 64)) + } + gaugeMu.Unlock() + + return out.String() +} + +// writeCounter renders one two-label counter family in a stable order. +func writeCounter(out *strings.Builder, name, help, firstLabel, secondLabel string, values map[labelPair]uint64) { + fmt.Fprintf(out, "# HELP %s %s\n", name, help) + fmt.Fprintf(out, "# TYPE %s counter\n", name) + keys := make([]labelPair, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].first != keys[j].first { + return keys[i].first < keys[j].first + } + return keys[i].second < keys[j].second + }) + for _, key := range keys { + fmt.Fprintf(out, "%s{%s=%q,%s=%q} %d\n", name, firstLabel, key.first, secondLabel, key.second, values[key]) + } +} + +// sortedMapKeys returns a map's keys in a stable order. +func sortedMapKeys(m map[string]*histogram) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// Gauge names published by the rest of the plugin. +const ( + // ContextStoreSizeGauge tracks in-flight gated requests. In a healthy runner + // it returns to zero when idle; a floor that keeps rising is the leak. + ContextStoreSizeGauge = contextStoreSizeMetric + + // ContextEvictedGauge counts contexts dropped because they expired or the + // store was full — requests that never reached their response phase. + ContextEvictedGauge = contextEvictedMetric + + // AuditDroppedGauge counts audit records lost to a full queue. An attacker + // who can generate load can suppress the record of their own access, so this + // must be alertable. + AuditDroppedGauge = auditDroppedMetric +) + +// Reset clears every metric. For tests. +func Reset() { + mu.Lock() + decisions = map[labelPair]uint64{} + dependencyCalls = map[labelPair]uint64{} + latency = map[string]*histogram{} + mu.Unlock() + + gaugeMu.Lock() + gauges = map[string]func() float64{} + gaugeMu.Unlock() +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..2ee80af --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,114 @@ +/* + * 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 metrics + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRenderExposition covers the whole payload: counters with their labels, the +// histogram's cumulative buckets, and gauges read at scrape time. +func TestRenderExposition(t *testing.T) { + Reset() + t.Cleanup(Reset) + + RecordDecision("allow", "") + RecordDecision("allow", "") + RecordDecision("deny", "") + // A deny caused by an outage must be distinguishable from a deny caused by + // consent: they mean opposite things operationally. + RecordDecision("deny", "by_policy") + + RecordDependencyCall(DependencyConsentManager, OutcomeSuccess, 20*time.Millisecond) + RecordDependencyCall(DependencyConsentManager, OutcomeError, 3*time.Second) + + RegisterGauge(ContextStoreSizeGauge, func() float64 { return 7 }) + + out := render() + + assert.Contains(t, out, `consent_decisions_total{decision="allow",fail_mode="none"} 2`) + assert.Contains(t, out, `consent_decisions_total{decision="deny",fail_mode="none"} 1`) + assert.Contains(t, out, `consent_decisions_total{decision="deny",fail_mode="by_policy"} 1`) + + assert.Contains(t, out, `consent_dependency_calls_total{dependency="consent_manager",outcome="success"} 1`) + assert.Contains(t, out, `consent_dependency_calls_total{dependency="consent_manager",outcome="error"} 1`) + + // 20ms falls in the 0.025 bucket, 3s does not; both are under +Inf. + assert.Contains(t, out, `consent_dependency_duration_seconds_bucket{dependency="consent_manager",le="0.025"} 1`) + assert.Contains(t, out, `consent_dependency_duration_seconds_bucket{dependency="consent_manager",le="5"} 2`) + assert.Contains(t, out, `consent_dependency_duration_seconds_bucket{dependency="consent_manager",le="+Inf"} 2`) + assert.Contains(t, out, `consent_dependency_duration_seconds_count{dependency="consent_manager"} 2`) + + assert.Contains(t, out, "consent_request_context_store_size 7") + assert.Contains(t, out, "# TYPE consent_decisions_total counter") + assert.Contains(t, out, "# TYPE consent_dependency_duration_seconds histogram") +} + +// TestGaugesAreReadAtScrapeTime verifies a gauge reflects the current value +// rather than the one at registration — the store size is the point. +func TestGaugesAreReadAtScrapeTime(t *testing.T) { + Reset() + t.Cleanup(Reset) + + size := 0 + RegisterGauge(ContextStoreSizeGauge, func() float64 { return float64(size) }) + + assert.Contains(t, render(), "consent_request_context_store_size 0") + size = 42 + assert.Contains(t, render(), "consent_request_context_store_size 42") +} + +// TestHandlerServesExposition verifies the HTTP surface and its content type. +func TestHandlerServesExposition(t *testing.T) { + Reset() + t.Cleanup(Reset) + RecordDecision("deny", "always_closed") + + recorder := httptest.NewRecorder() + Handler().ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil)) + + require.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Header().Get("Content-Type"), "text/plain") + assert.Contains(t, recorder.Body.String(), `consent_decisions_total{decision="deny",fail_mode="always_closed"} 1`) +} + +// TestRenderIsStable verifies the output order does not depend on map iteration, +// so a scrape diff reflects real change. +func TestRenderIsStable(t *testing.T) { + Reset() + t.Cleanup(Reset) + + for _, decision := range []string{"deny", "allow"} { + RecordDecision(decision, "") + } + RecordDependencyCall(DependencyOwnerResolver, OutcomeSuccess, time.Millisecond) + RecordDependencyCall(DependencyConsentManager, OutcomeSuccess, time.Millisecond) + RegisterGauge(AuditDroppedGauge, func() float64 { return 1 }) + RegisterGauge(ContextStoreSizeGauge, func() float64 { return 2 }) + + first := render() + for i := 0; i < 20; i++ { + assert.Equal(t, first, render()) + } +} diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 783a661..45b2ae4 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -32,6 +32,7 @@ import ( "consent-plugin/internal/consent" "consent-plugin/internal/jwt" "consent-plugin/internal/logging" + "consent-plugin/internal/metrics" "consent-plugin/internal/ownerresolver" "context" "errors" @@ -198,6 +199,10 @@ type responseOutcome struct { resource string method string checked []checkedOwner + // failMode names why the decision could not be reached normally, so a deny + // caused by an outage is not counted as a deny caused by consent. Empty for + // an ordinary consent verdict. + failMode string } // checkedOwner is one data owner's consent decision within a response. @@ -237,6 +242,7 @@ func (c *ConsentFilter) ResponseFilter(conf interface{}, w pkgHTTP.Response) { } outcome := c.evaluate(cfg, w) + metrics.RecordDecision(outcome.decision, outcome.failMode) recordAudit(cfg, outcome) if outcome.decision == decisionDeny { denyResponse(w, cfg) @@ -325,12 +331,14 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke resolveParties.Provider = providerSD resolverClient := ownerresolver.NewClient(cfg.OwnerResolverURL, cfg.OwnerResolverTimeout) + resolveStarted := time.Now() result, err := resolverClient.Resolve(phaseCtx, ownerresolver.Resource{ Service: cfg.Service, Method: reqCtx.Method, Path: reqCtx.Path, ContentType: contentType, }, resolveParties, body) + metrics.RecordDependencyCall(metrics.DependencyOwnerResolver, outcomeOf(err), time.Since(resolveStarted)) if err != nil { logging.ErrorfEvery("resolver-error", "ResponseFilter: owner resolver error for request %s: %s", key, logging.Sanitize(err.Error())) return failOutcome(cfg, failByPolicy, "owner resolver error: "+err.Error(), key, nil) @@ -388,6 +396,14 @@ func distinctClaims(claims []ownerresolver.Claim) ([]ownerClaim, error) { return distinct, nil } +// outcomeOf maps a call's error to the metric's outcome label. +func outcomeOf(err error) string { + if err != nil { + return metrics.OutcomeError + } + return metrics.OutcomeSuccess +} + // maxConcurrentConsentChecks bounds how many per-owner checks are in flight at // once. Serial checks made the response latency the sum of every owner's; an // unbounded fan-out would instead make one response a burst against the @@ -442,7 +458,9 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo results[i].request = req ownerResource := resourceOrPath(claim.dataResource, reqCtx.Path) + started := time.Now() resp, err := client.CheckConsent(checksCtx, req) + metrics.RecordDependencyCall(metrics.DependencyConsentManager, outcomeOf(err), time.Since(started)) switch { case err != nil: results[i].err = err @@ -549,6 +567,14 @@ const ( failAlwaysClosed ) +// String names the fail mode for metrics and logs. +func (m failMode) String() string { + if m == failAlwaysClosed { + return "always_closed" + } + return "by_policy" +} + // failOutcome builds the outcome for an unresolved consent check. mode decides // whether the operator's fail policy applies at all. req may be nil when no // request context was captured. @@ -557,7 +583,7 @@ func failOutcome(cfg *Config, mode failMode, reason, requestID string, req *cons if mode == failByPolicy && cfg.IsFailOpen() { decision = decisionAllow } - o := responseOutcome{decision: decision, reason: reason, requestID: requestID} + o := responseOutcome{decision: decision, reason: reason, requestID: requestID, failMode: mode.String()} if req != nil { o.subject = req.Subject o.resource = req.Resource diff --git a/internal/plugin/context.go b/internal/plugin/context.go index d84e4d5..482a79c 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -19,6 +19,7 @@ package plugin import ( "consent-plugin/internal/logging" + "consent-plugin/internal/metrics" "fmt" "sync" "time" @@ -98,6 +99,13 @@ var ( janitorOnce sync.Once ) +func init() { + // Publish the store's size and eviction count so a leak is observable rather + // than only inferable from memory growth. + metrics.RegisterGauge(metrics.ContextStoreSizeGauge, func() float64 { return float64(RequestContextStoreSize()) }) + metrics.RegisterGauge(metrics.ContextEvictedGauge, func() float64 { return float64(RequestContextsEvicted()) }) +} + // startContextJanitor launches the background sweep exactly once. It is started // lazily from the first Store so that importing the package (as tests and the // runner registration do) never leaves a goroutine running for nothing. diff --git a/main.go b/main.go index 9f6999c..4f78bf4 100644 --- a/main.go +++ b/main.go @@ -22,21 +22,40 @@ package main import ( "consent-plugin/internal/audit" + "consent-plugin/internal/logging" + "consent-plugin/internal/metrics" // Import the plugin package to trigger init() registration. _ "consent-plugin/internal/plugin" + "errors" + "net/http" "os" "os/signal" "syscall" + "time" "github.com/apache/apisix-go-plugin-runner/pkg/runner" ) +// EnvMetricsAddress is the listen address for the Prometheus metrics endpoint +// (e.g. ":9091"). Metrics are off unless it is set: the runner is normally +// reached only over its unix socket, so opening a TCP port is an explicit +// decision for the deployment to make. +const EnvMetricsAddress = "CONSENT_METRICS_ADDRESS" + +// metricsPath is where the metrics are exposed. +const metricsPath = "/metrics" + +// metricsServerTimeout bounds a metrics request, so a stuck scraper cannot hold +// a connection open indefinitely. +const metricsServerTimeout = 10 * time.Second + func main() { // The audit queue is flushed by a background worker on an interval, so a // redeploy or restart would otherwise discard up to one flush interval of // access decisions — silently, from the record whose whole purpose is to be // complete. runner.Run blocks, so the flush is driven from its own goroutine. go flushAuditOnShutdown() + go serveMetrics() runner.Run(runner.RunnerConfig{}) } @@ -49,3 +68,27 @@ func flushAuditOnShutdown() { <-signals audit.ShutdownAll() } + +// serveMetrics exposes the plugin's Prometheus metrics when an address is +// configured. A component that can deny production traffic should not be +// observable only through unstructured logs. +func serveMetrics() { + address := os.Getenv(EnvMetricsAddress) + if address == "" { + return + } + mux := http.NewServeMux() + mux.Handle(metricsPath, metrics.Handler()) + server := &http.Server{ + Addr: address, + Handler: mux, + ReadHeaderTimeout: metricsServerTimeout, + ReadTimeout: metricsServerTimeout, + WriteTimeout: metricsServerTimeout, + } + logging.Infof("serving metrics on %s%s", address, metricsPath) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + // The gate keeps working without metrics; do not take the runner down. + logging.Errorf("metrics endpoint stopped: %s", logging.Sanitize(err.Error())) + } +} From e5e65da517b10f7ba9d9a53eb612a67cbad81639 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:20:55 +0200 Subject: [PATCH 24/41] fix(config): bound participant_token_ttl so it cannot overflow (L-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `time.Duration(cfg.ParticipantTokenTTL) * time.Second` was computed from a field nothing validated. A large enough value overflows into a NEGATIVE duration, and the cached token's expiry then lands in the past — so every request refetches a token, hammering the token service, from a config that looked merely eccentric. `participant_token_ttl` now defaults to `DefaultParticipantTokenTTL` (3000s, matching the consent client's own default, and matching what the README already claimed) and is bounded to 1–86400. A day is far longer than any token this plugin is issued, and comfortably below the overflow range, so the conversion at the call site is safe by construction — with a comment there saying why. Co-Authored-By: Claude Opus 5 --- internal/plugin/config.go | 26 ++++++++++++++++++++++++-- internal/plugin/config_test.go | 24 ++++++++++++++++++++++++ internal/plugin/consent.go | 6 ++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/internal/plugin/config.go b/internal/plugin/config.go index c81f809..b84be71 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -63,6 +63,19 @@ const ( MinOwnerResolverTimeout = 1 MaxOwnerResolverTimeout = 60000 + // DefaultParticipantTokenTTL is how long, in seconds, a fetched participant + // token is cached when the route does not say. It matches the consent + // client's own default. + DefaultParticipantTokenTTL = 3000 + + // MinParticipantTokenTTL and MaxParticipantTokenTTL bound the token cache + // lifetime in seconds. The upper bound is a day — far longer than any token + // this plugin is issued — and it also keeps the value away from the range + // where `time.Duration(ttl) * time.Second` overflows into a negative + // duration, which would make every cached token instantly expired. + MinParticipantTokenTTL = 1 + MaxParticipantTokenTTL = 86400 + // apiPrefixSeparator is the path separator an API prefix must start with. A // prefix without it is silently concatenated into a malformed URL. apiPrefixSeparator = "/" @@ -223,8 +236,9 @@ type Config struct { 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. + // (defaults to DefaultParticipantTokenTTL). The token service reports its own + // lifetime; the shorter of the two wins. Ignored for a static token. Bounded + // by Validate, so it cannot overflow when converted to a time.Duration. ParticipantTokenTTL int `json:"participant_token_ttl,omitempty"` // ParticipantToken is an optional *static*, pre-obtained access token for the @@ -314,6 +328,9 @@ func (c *Config) applyDefaults() { if c.MaxOwnersPerResponse == 0 { c.MaxOwnersPerResponse = DefaultMaxOwnersPerResponse } + if c.ParticipantTokenTTL == 0 { + c.ParticipantTokenTTL = DefaultParticipantTokenTTL + } if c.ConsumerClaim == "" { c.ConsumerClaim = DefaultConsumerClaim } @@ -405,6 +422,11 @@ func (c *Config) Validate() error { MinResponsePhaseTimeout, MaxResponsePhaseTimeout, c.ResponsePhaseTimeout) } + if c.ParticipantTokenTTL < MinParticipantTokenTTL || c.ParticipantTokenTTL > MaxParticipantTokenTTL { + return fmt.Errorf("config validation: participant_token_ttl must be between %d and %d, got %d", + MinParticipantTokenTTL, MaxParticipantTokenTTL, c.ParticipantTokenTTL) + } + if c.MaxOwnersPerResponse < MinMaxOwnersPerResponse || c.MaxOwnersPerResponse > MaxMaxOwnersPerResponse { return fmt.Errorf("config validation: max_owners_per_response must be between %d and %d, got %d", MinMaxOwnersPerResponse, MaxMaxOwnersPerResponse, c.MaxOwnersPerResponse) diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index 3922af3..c66fd6b 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -362,6 +362,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverTimeout: DefaultOwnerResolverTimeout, ConsentAPIPrefix: DefaultConsentAPIPrefix, ParticipantToken: "static-token", + ParticipantTokenTTL: DefaultParticipantTokenTTL, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, JWTHeaderName: DefaultJWTHeaderName, @@ -386,6 +387,7 @@ func TestConfig_Validate(t *testing.T) { ConsentAPITimeout: DefaultConsentAPITimeout, ConsentAPIPrefix: DefaultConsentAPIPrefix, OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ParticipantTokenTTL: DefaultParticipantTokenTTL, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -393,6 +395,25 @@ func TestConfig_Validate(t *testing.T) { wantErr: true, errSubstr: "owner_resolver_url is required", }, + { + name: "an out-of-range participant_token_ttl fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ParticipantToken: "static-token", + // Large enough that `time.Duration(ttl) * time.Second` overflows + // into a negative duration, expiring every token immediately. + ParticipantTokenTTL: 1 << 60, + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "participant_token_ttl must be between", + }, { name: "no credential source fails", config: Config{ @@ -401,6 +422,7 @@ func TestConfig_Validate(t *testing.T) { ConsentAPIPrefix: DefaultConsentAPIPrefix, OwnerResolverURL: "https://owner-resolver.example.com/resolve", OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ParticipantTokenTTL: DefaultParticipantTokenTTL, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -417,6 +439,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverURL: "https://owner-resolver.example.com/resolve", OwnerResolverTimeout: DefaultOwnerResolverTimeout, ParticipantToken: "static-token", + ParticipantTokenTTL: DefaultParticipantTokenTTL, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -433,6 +456,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverURL: "https://owner-resolver.example.com/resolve", OwnerResolverTimeout: MaxOwnerResolverTimeout + 1, ParticipantToken: "static-token", + ParticipantTokenTTL: DefaultParticipantTokenTTL, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 45b2ae4..4c6998e 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -535,8 +535,10 @@ func clientConfigFromCfg(cfg *Config) consent.ClientConfig { ParticipantToken: cfg.ParticipantToken, TokenServiceURL: cfg.TokenServiceURL, TokenAudience: cfg.TokenAudience, - TokenTTL: time.Duration(cfg.ParticipantTokenTTL) * time.Second, - TimeoutMs: cfg.ConsentAPITimeout, + // Safe to convert: Validate bounds ParticipantTokenTTL well below the + // point where the multiplication overflows a time.Duration. + TokenTTL: time.Duration(cfg.ParticipantTokenTTL) * time.Second, + TimeoutMs: cfg.ConsentAPITimeout, } } From 5a6f8c99e1ebff10c61fd00017b8b011abbd9582 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:21:23 +0200 Subject: [PATCH 25/41] docs: add SECURITY.md and CODEOWNERS (L-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repository that gates access to personal data had no private path for reporting a vulnerability and no required reviewers. Someone finding a way to make the gate release data had nowhere to send it but a public issue. - `SECURITY.md` points at GitHub private vulnerability reporting, says what to include (for a gate, "the response was released" is the key fact), and draws the scope line explicitly — a response released without consent, a consent for one consumer authorising another, ownership taken from the requestor, side channels around a denial, and audit suppression are all in scope; the absence of JWT signature verification is not, since it is a documented deployment requirement rather than a defect. - `.github/CODEOWNERS` requires review everywhere, calling out the decision path, the audit trail and the CI/release configuration by name. - CONTRIBUTING.md points at the security policy before it talks about PRs. Co-Authored-By: Claude Opus 5 --- .github/CODEOWNERS | 16 +++++++++++++++ CONTRIBUTING.md | 5 +++++ SECURITY.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cc9930c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +# Review is required on every change: this repository decides whether personal +# data is released, so nothing here is too small to be looked at by someone else. +* @wistefan + +# The decision path itself. A change here can turn the gate into a pass-through. +/internal/plugin/ @wistefan +/internal/consent/ @wistefan +/internal/ownerresolver/ @wistefan + +# The audit trail, which is the evidence that the gate worked. +/internal/audit/ @wistefan + +# Security policy, CI gates and the release path. +/SECURITY.md @wistefan +/.github/ @wistefan +/.gitea/ @wistefan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db6795f..a72a569 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,10 @@ # Contributing +## Reporting a vulnerability + +Do not open a public issue. See [SECURITY.md](SECURITY.md) — this plugin decides +whether personal data is released, so a defect in it is handled privately first. + ## Pull requests - Target the `main` branch. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d2fa3b3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +`consent-plugin` gates access to personal data. A defect in it can release data +a subject never consented to, so please treat findings here as sensitive. + +## Reporting a vulnerability + +**Do not open a public issue for a suspected vulnerability.** + +Report it privately through GitHub's +[private vulnerability reporting](https://github.com/wistefan/consent-plugin/security/advisories/new) +on this repository. If that is unavailable to you, contact a maintainer listed in +[CODEOWNERS](.github/CODEOWNERS) directly. + +Please include: + +- the version, image tag or commit you tested, +- the plugin configuration in use, with secrets redacted, +- what you observed and what you expected — for a gate, "the response was + released" or "the response was denied" is the key fact, +- a minimal reproduction if you have one. + +We aim to acknowledge a report within three working days and to agree a +disclosure timeline with you before anything is published. + +## What is in scope + +Anything that changes the access decision or leaks data around it, including: + +- a response released without a granted consent from the resolved data owner, +- a consent granted to one consuming participant authorising another, +- data ownership being taken from the requestor rather than from the data, +- information about denied data reaching the client (headers, timing, counts), +- credentials or personal data reaching logs, metrics, or the audit sink in a + form that was not intended, +- a way to make the plugin fail open that the operator did not configure, +- suppressing or forging audit records. + +## What is out of scope + +- The absence of JWT signature verification. The plugin decodes the token and + relies on an authentication plugin earlier in the route; this is documented in + the README and is a deployment requirement, not a defect in the plugin. +- Findings that require an already-compromised APISIX instance or plugin runner. +- The local development stack under `dev/`, which uses fixed credentials on + purpose and is not for deployment. + +## Supported versions + +Fixes land on `main` and are published as a new release. Older tags are not +patched; please upgrade. From e7b4f5228f8508e8bea876aa4b8d5804abaedfcf Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:21:37 +0200 Subject: [PATCH 26/41] docs: document the local Go toolchain requirement (L-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go.mod` requires Go 1.26, and on a machine with an older system Go where `GOTOOLCHAIN` cannot download, the build fails with `toolchain not available` — which reads like "Go 1.26 does not exist" rather than "the download was blocked". The bare major version is what fails to resolve; a full patch version in the module cache works. CONTRIBUTING.md now says so, with the commands to find a cached toolchain and pin it, and points at where CI pins golangci-lint so a contributor lints with the same version the pipeline uses. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a72a569..6992844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,30 @@ Do not open a public issue. See [SECURITY.md](SECURITY.md) — this plugin decides whether personal data is released, so a defect in it is handled privately first. +## Local toolchain + +`go.mod` requires **Go 1.26**. If your system Go is older, the build tries to +fetch the toolchain automatically — and in an environment where `GOTOOLCHAIN` +cannot download (an air-gapped machine, a restricted proxy) it fails with +`toolchain not available`, which reads like "Go 1.26 does not exist" rather than +"the download was blocked". + +Either install Go 1.26+ directly, or point at a full patch version already in the +module cache and disable switching: + +```bash +ls -d "$(go env GOMODCACHE)"/golang.org/toolchain@*/ # what is cached +export PATH="$(go env GOMODCACHE)/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64/bin:$PATH" +export GOTOOLCHAIN=local +``` + +Note the bare major version (`go1.26`) is what fails; the full patch version +(`go1.26.7`) is what the cache holds. + +`golangci-lint` must match the version CI pins — see +`.github/workflows/style-guide.yml` and `.gitea/workflows/ci.yaml`, which are +kept in step with each other. + ## Pull requests - Target the `main` branch. From 3f6698da866cfbc91b532a99cb3a3110c5901af7 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:22:36 +0200 Subject: [PATCH 27/41] fix(plugin): cap the payload forwarded to the OwnerResolver (L-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response phase read the whole upstream body, then `ownerresolver.Resolve` ran `json.Valid` over it and `json.Marshal` copied it again into the request envelope as a `json.RawMessage`. Peak footprint is therefore roughly **3x** the body size per in-flight request, on top of APISIX's own buffering of the same response — so a handful of concurrent large-collection responses can drive the runner's memory well past what the response size suggests. Nothing bounded it, and transport security does not help: cluster mTLS does not make the copy smaller. New `max_resolve_body_bytes` (default 1 MiB, range 1–100 MiB). A larger body is **denied** rather than forwarded — a body too large to examine is not a body the gate can vouch for — and, like the owner cap, it denies regardless of `fail_open`, since it is a deliberate limit rather than an outage. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++-- internal/plugin/config.go | 27 +++++++++++++++++++++++++++ internal/plugin/config_test.go | 24 ++++++++++++++++++++++++ internal/plugin/consent.go | 11 +++++++++++ internal/plugin/consent_test.go | 29 +++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a39b73d..be616ba 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ participant** for the contract lookup and to scope the consent match. before the runner sees it (`ReadBody()` is a blocking extra-info RPC over the unix socket). Gated routes therefore do not stream, and a large response is a memory multiplier across APISIX and the runner. Keep gated routes to bounded -responses, and note that `response_phase_timeout` and `max_owners_per_response` -(below) bound how long the response is held and how many owners are checked. +responses, and note that `response_phase_timeout`, `max_owners_per_response` and +`max_resolve_body_bytes` (below) bound how long the response is held, how many +owners are checked, and how large a payload is forwarded to the resolver. Per-owner consent checks run concurrently (up to 8 in flight) and short-circuit on the first denial, so latency is not the sum over owners. @@ -107,6 +108,7 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `owner_resolver_timeout` | `int` | No | `2000` | Per-call timeout in ms for `/resolve`. Range 1–60000. | | `service` | `string` | No | — | Logical dataset id sent to the OwnerResolver as `resource.service`, so it can select the rule for this route. | | `response_phase_timeout` | `int` | No | `10000` | Budget in ms for the **entire** response phase — party lookups, `/resolve`, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. | +| `max_resolve_body_bytes` | `int` | No | `1048576` | Maximum upstream body forwarded to the OwnerResolver. A larger body is denied rather than copied — the body is held whole, validated and marshalled again, so the peak footprint is ~3× its size per in-flight request on top of APISIX's own buffering. Range 1–104857600. | | `max_owners_per_response` | `int` | No | `50` | Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. | | `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Supports array indexing (`verifiableCredential[0].issuer`), and a bare segment landing on an array traverses its first element — a Verifiable Presentation routinely carries `verifiableCredential` as an array. Used for the contract lookup and to scope the consent match — never for ownership. | | `consent_api_prefix` | `string` | No | `/v1` | API prefix prepended to endpoint paths (the consent-manager's `API_PREFIX`). Must start with `/`; a trailing `/` is trimmed. | diff --git a/internal/plugin/config.go b/internal/plugin/config.go index b84be71..e736fdc 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -53,6 +53,15 @@ const ( MinMaxOwnersPerResponse = 1 MaxMaxOwnersPerResponse = 1000 + // DefaultMaxResolveBodyBytes is the default cap on the upstream body + // forwarded to the OwnerResolver (1 MiB). + DefaultMaxResolveBodyBytes = 1 << 20 + + // MinMaxResolveBodyBytes and MaxMaxResolveBodyBytes bound that cap (up to + // 100 MiB, which is already well past what a gated route should return). + MinMaxResolveBodyBytes = 1 + MaxMaxResolveBodyBytes = 100 << 20 + // MinResponsePhaseTimeout and MaxResponsePhaseTimeout bound the response-phase // budget in milliseconds (120s is already far beyond any sane gateway timeout). MinResponsePhaseTimeout = 1 @@ -201,6 +210,16 @@ type Config struct { // Defaults to DefaultResponsePhaseTimeout. ResponsePhaseTimeout int `json:"response_phase_timeout,omitempty"` + // MaxResolveBodyBytes caps the upstream body forwarded to the OwnerResolver. + // + // The body is read whole, validated as JSON, and marshalled again into the + // resolve envelope, so the peak footprint is roughly 3x its size per in-flight + // request — on top of APISIX's own buffering of the same response. A handful + // of concurrent large-collection responses can therefore drive the runner's + // memory well past what the response size suggests. A body above this is + // denied rather than forwarded. Defaults to DefaultMaxResolveBodyBytes. + MaxResolveBodyBytes int `json:"max_resolve_body_bytes,omitempty"` + // MaxOwnersPerResponse caps how many distinct data owners are checked for a // single response. A response resolving to more owners than this is denied // rather than answered after an unbounded number of consent calls. @@ -328,6 +347,9 @@ func (c *Config) applyDefaults() { if c.MaxOwnersPerResponse == 0 { c.MaxOwnersPerResponse = DefaultMaxOwnersPerResponse } + if c.MaxResolveBodyBytes == 0 { + c.MaxResolveBodyBytes = DefaultMaxResolveBodyBytes + } if c.ParticipantTokenTTL == 0 { c.ParticipantTokenTTL = DefaultParticipantTokenTTL } @@ -422,6 +444,11 @@ func (c *Config) Validate() error { MinResponsePhaseTimeout, MaxResponsePhaseTimeout, c.ResponsePhaseTimeout) } + if c.MaxResolveBodyBytes < MinMaxResolveBodyBytes || c.MaxResolveBodyBytes > MaxMaxResolveBodyBytes { + return fmt.Errorf("config validation: max_resolve_body_bytes must be between %d and %d, got %d", + MinMaxResolveBodyBytes, MaxMaxResolveBodyBytes, c.MaxResolveBodyBytes) + } + if c.ParticipantTokenTTL < MinParticipantTokenTTL || c.ParticipantTokenTTL > MaxParticipantTokenTTL { return fmt.Errorf("config validation: participant_token_ttl must be between %d and %d, got %d", MinParticipantTokenTTL, MaxParticipantTokenTTL, c.ParticipantTokenTTL) diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index c66fd6b..a27ae19 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -363,6 +363,7 @@ func TestConfig_Validate(t *testing.T) { ConsentAPIPrefix: DefaultConsentAPIPrefix, ParticipantToken: "static-token", ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, JWTHeaderName: DefaultJWTHeaderName, @@ -388,6 +389,7 @@ func TestConfig_Validate(t *testing.T) { ConsentAPIPrefix: DefaultConsentAPIPrefix, OwnerResolverTimeout: DefaultOwnerResolverTimeout, ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -395,6 +397,24 @@ func TestConfig_Validate(t *testing.T) { wantErr: true, errSubstr: "owner_resolver_url is required", }, + { + name: "an out-of-range max_resolve_body_bytes fails", + config: Config{ + ConsentAPIURL: "https://consent.example.com", + ConsentAPITimeout: DefaultConsentAPITimeout, + ConsentAPIPrefix: DefaultConsentAPIPrefix, + OwnerResolverURL: "https://owner-resolver.example.com/resolve", + OwnerResolverTimeout: DefaultOwnerResolverTimeout, + ParticipantToken: "static-token", + ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: MaxMaxResolveBodyBytes + 1, + ResponsePhaseTimeout: DefaultResponsePhaseTimeout, + MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + DenyStatusCode: DefaultDenyStatusCode, + }, + wantErr: true, + errSubstr: "max_resolve_body_bytes must be between", + }, { name: "an out-of-range participant_token_ttl fails", config: Config{ @@ -407,6 +427,7 @@ func TestConfig_Validate(t *testing.T) { // Large enough that `time.Duration(ttl) * time.Second` overflows // into a negative duration, expiring every token immediately. ParticipantTokenTTL: 1 << 60, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -423,6 +444,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverURL: "https://owner-resolver.example.com/resolve", OwnerResolverTimeout: DefaultOwnerResolverTimeout, ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -440,6 +462,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverTimeout: DefaultOwnerResolverTimeout, ParticipantToken: "static-token", ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, @@ -457,6 +480,7 @@ func TestConfig_Validate(t *testing.T) { OwnerResolverTimeout: MaxOwnerResolverTimeout + 1, ParticipantToken: "static-token", ParticipantTokenTTL: DefaultParticipantTokenTTL, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, DenyStatusCode: DefaultDenyStatusCode, diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 4c6998e..9fe483b 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -295,6 +295,17 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke return failOutcome(cfg, failByPolicy, "read upstream body: "+err.Error(), key, nil) } + if len(body) > cfg.MaxResolveBodyBytes { + // Forwarding it would copy the body twice more (json.Valid, then the + // marshalled envelope) on top of APISIX's own buffering. Deny instead: + // a body too large to examine is not a body we can vouch for. + logging.WarnfEvery("resolve-body-cap", "ResponseFilter: upstream body of %d bytes for request %s exceeds max_resolve_body_bytes=%d; denying", + len(body), key, cfg.MaxResolveBodyBytes) + return failOutcome(cfg, failAlwaysClosed, + fmt.Sprintf("upstream body of %d bytes exceeds max_resolve_body_bytes=%d", len(body), cfg.MaxResolveBodyBytes), + key, nil) + } + contentType := responseContentType(w) // Parties are for CONTRACT identification only - never for ownership. The diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 839b8aa..453faa6 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -25,6 +25,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "sync" "testing" "time" @@ -302,6 +303,7 @@ func newTestConfig(consentAPIURL, resolverURL string) *Config { OwnerResolverTimeout: DefaultOwnerResolverTimeout, ResponsePhaseTimeout: DefaultResponsePhaseTimeout, MaxOwnersPerResponse: DefaultMaxOwnersPerResponse, + MaxResolveBodyBytes: DefaultMaxResolveBodyBytes, ConsumerClaim: DefaultConsumerClaim, JWTHeaderName: DefaultJWTHeaderName, ConsentKey: "test-consent-key", @@ -1165,3 +1167,30 @@ func TestResponseContentType(t *testing.T) { assert.Empty(t, responseContentType(resp)) }) } + +// TestResponseFilter_BodyCapDenies verifies an oversized upstream body is denied +// rather than forwarded. The resolver call holds the body whole, validates it +// and marshals it again, so forwarding a large collection response multiplies +// the runner's memory on top of APISIX's own buffering. +func TestResponseFilter_BodyCapDenies(t *testing.T) { + clearContextStore() + + server := newUncalledConsentManager(t) + defer server.Close() + resolver := newUncalledOwnerResolver(t) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.MaxResolveBodyBytes = 32 + // The cap is a deliberate limit, not an outage, so fail_open must not lift it. + cfg.FailOpen = boolPtr(true) + + const id = uint32(250) + storeRequest(id) + resp := newMockResponse(id, []byte(`{"padding":"`+strings.Repeat("x", 64)+`"}`)) + + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "a body too large to examine must be denied, not forwarded") +} From 6528a894824d43a6c4fe563bd6fe706842e13a75 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:23:45 +0200 Subject: [PATCH 28/41] fix(ownerresolver): distinguish an unreadable payload from no payload (L-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `json.Valid(payload)` failed, the resolve envelope was sent with `encoding: "none"` — exactly as it was when the response carried no body at all. The resolver therefore could not tell "this response carried a payload I could not parse" from "this response had no payload", and fell back to resolving ownership from the resource descriptor alone. So a malformed-but-personal payload was judged without ever being inspected: a truncated write, a content-type mismatch, an upstream answering XML or NDJSON on a route declared JSON. The gate looked at nothing and let it through. The envelope now has a third encoding, `opaque`, carrying the declared content type and the payload's size but not the payload. The resolver can act on the distinction — fail closed on a body it was told exists but cannot be read — rather than being told a falsehood about it. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++ internal/ownerresolver/client.go | 60 ++++++++++++--- internal/ownerresolver/client_test.go | 107 ++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index be616ba..e456659 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ The plugin is attached to a route in **both** external-plugin phases: The decision is a coarse allow/deny and is **independent of the response body's shape**, so an empty or non-JSON personal-data response is still gated. +The payload is described to the resolver in one of three ways — `json` (parsed +and carried), `none` (there was no payload), and `opaque` (there was one but it +could not be parsed, sent with its declared content type and size). The last two +are deliberately distinct: an unreadable personal-data payload must not look to +the resolver like no payload at all. + > The `$request_id` correlation is required: `ext-plugin-pre-req` and `ext-plugin-post-resp` are separate RPCs to the runner and do **not** share the runner's per-call `ID()`. ### Ownership comes from the data, never from the requestor diff --git a/internal/ownerresolver/client.go b/internal/ownerresolver/client.go index bf6b32d..cf84c18 100644 --- a/internal/ownerresolver/client.go +++ b/internal/ownerresolver/client.go @@ -34,9 +34,24 @@ import ( // Body encodings understood by the resolver. const ( + // encodingJSON carries the payload verbatim, parsed. encodingJSON = "json" + + // encodingNone means the response carried no payload at all. encodingNone = "none" + // encodingOpaque means the response carried a payload the plugin could not + // parse as JSON. + // + // It exists because sending such a body as encodingNone made "this response + // carried a payload I could not read" indistinguishable from "this response + // had no payload". The resolver then judged ownership from the resource + // descriptor alone, so a malformed-but-personal payload — a truncated write, + // a content-type mismatch, an upstream answering XML or NDJSON on a route + // declared JSON — was released without ever being inspected. With the two + // cases separated the resolver can fail closed on the one it cannot read. + encodingOpaque = "opaque" + // DefaultTimeoutMs is the default per-call timeout for /resolve. DefaultTimeoutMs = 2000 @@ -82,8 +97,14 @@ type resourceDescriptor struct { } type bodyDescriptor struct { - Encoding string `json:"encoding"` - Content json.RawMessage `json:"content,omitempty"` + Encoding string `json:"encoding"` + // Content is the payload, present only for encodingJSON. + Content json.RawMessage `json:"content,omitempty"` + // ContentType is what the upstream declared, sent with encodingOpaque so the + // resolver knows what it was handed and how much it was. + ContentType string `json:"contentType,omitempty"` + // Size is the payload's length in bytes, sent with encodingOpaque. + Size int `json:"size,omitempty"` } // Parties names the exchange participants. It exists ONLY so the resolver can @@ -131,17 +152,20 @@ type Resource struct { 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". +// Resolve asks the OwnerResolver about a payload. +// +// The body is described in one of three ways, and the distinction matters: +// "json" carries the payload, "none" says there was no payload, and "opaque" +// says there WAS one but it could not be parsed. Collapsing the last two would +// let an unreadable personal-data payload be judged from the resource descriptor +// alone. +// +// The parties, when known, are 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)} - } + reqBody := describeBody(payload, res.ContentType) req := resolveRequest{ Resource: resourceDescriptor(res), Body: reqBody, @@ -181,6 +205,18 @@ func (c *Client) Resolve(ctx context.Context, res Resource, p Parties, payload [ return out, nil } +// describeBody classifies the upstream payload for the resolve envelope. +func describeBody(payload []byte, contentType string) *bodyDescriptor { + switch { + case len(payload) == 0: + return &bodyDescriptor{Encoding: encodingNone} + case json.Valid(payload): + return &bodyDescriptor{Encoding: encodingJSON, Content: json.RawMessage(payload)} + default: + return &bodyDescriptor{Encoding: encodingOpaque, ContentType: contentType, Size: len(payload)} + } +} + func truncate(b []byte) string { const limit = 256 if len(b) <= limit { diff --git a/internal/ownerresolver/client_test.go b/internal/ownerresolver/client_test.go index 0b12f0a..1c5e3b1 100644 --- a/internal/ownerresolver/client_test.go +++ b/internal/ownerresolver/client_test.go @@ -109,3 +109,110 @@ func TestResolve_Non2xxIsError(t *testing.T) { t.Fatal("expected error on non-2xx resolver response") } } + +// TestDescribeBody verifies the three body encodings stay distinguishable. +// +// A payload the plugin cannot parse used to be described exactly like no payload +// at all, so the resolver judged ownership from the resource descriptor alone +// and a malformed-but-personal response (a truncated write, a content-type +// mismatch, an upstream answering XML on a route declared JSON) was released +// without ever being inspected. +func TestDescribeBody(t *testing.T) { + tests := []struct { + name string + payload []byte + contentType string + wantEncoding string + wantContent string + wantContentType string + wantSize int + }{ + { + name: "no payload", + payload: nil, + wantEncoding: encodingNone, + }, + { + name: "empty payload is no payload", + payload: []byte{}, + wantEncoding: encodingNone, + }, + { + name: "valid JSON is carried verbatim", + payload: []byte(`{"id":"urn:entity:1"}`), + contentType: "application/json", + wantEncoding: encodingJSON, + wantContent: `{"id":"urn:entity:1"}`, + }, + { + name: "unparseable payload is opaque, not absent", + payload: []byte("alice@example.org"), + contentType: "application/xml", + wantEncoding: encodingOpaque, + wantContentType: "application/xml", + wantSize: 49, + }, + { + name: "truncated JSON is opaque, not absent", + payload: []byte(`{"id":"urn:entity`), + contentType: "application/json", + wantEncoding: encodingOpaque, + wantContentType: "application/json", + wantSize: 17, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeBody(tt.payload, tt.contentType) + if got.Encoding != tt.wantEncoding { + t.Fatalf("encoding = %q, want %q", got.Encoding, tt.wantEncoding) + } + if string(got.Content) != tt.wantContent { + t.Errorf("content = %q, want %q", string(got.Content), tt.wantContent) + } + if got.ContentType != tt.wantContentType { + t.Errorf("contentType = %q, want %q", got.ContentType, tt.wantContentType) + } + if got.Size != tt.wantSize { + t.Errorf("size = %d, want %d", got.Size, tt.wantSize) + } + }) + } +} + +// TestResolve_SendsOpaqueBodyForUnparseablePayload verifies the distinction +// survives onto the wire, so the resolver can act on it. +func TestResolve_SendsOpaqueBodyForUnparseablePayload(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.Header().Set("Content-Type", contentTypeJSON) + _, _ = w.Write([]byte(`{"consentRequired":true,"claims":[{"ownerId":"did:key:zOwner"}]}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, DefaultTimeoutMs) + _, err := c.Resolve(context.Background(), + Resource{Service: "svc", Method: "GET", Path: "/p", ContentType: "application/xml"}, + Parties{Consumer: testConsumer}, + []byte("")) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + if gotReq.Body == nil { + t.Fatal("no body descriptor was sent") + } + if gotReq.Body.Encoding != encodingOpaque { + t.Errorf("encoding = %q, want %q — an unreadable payload must not look like no payload", + gotReq.Body.Encoding, encodingOpaque) + } + if gotReq.Body.ContentType != "application/xml" { + t.Errorf("contentType = %q, want application/xml", gotReq.Body.ContentType) + } + if len(gotReq.Body.Content) != 0 { + t.Errorf("an opaque body must not carry content, got %q", string(gotReq.Body.Content)) + } +} From 46a6614a9d97aa02003a924019710a26c7ac41cb Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:24:09 +0200 Subject: [PATCH 29/41] chore: keep review.md out of the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review document was swept into the C-1 commit by `git add -A`. It is a local working note, not a repository artifact — untracked here and ignored so it cannot be committed by accident again. The file itself stays in the working tree. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + review.md | 653 ----------------------------------------------------- 2 files changed, 3 insertions(+), 653 deletions(-) delete mode 100644 review.md diff --git a/.gitignore b/.gitignore index 5c689d1..8544e17 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ coverage.out # OS files .DS_Store + +# Local review/working notes, not part of the repository. +review.md diff --git a/review.md b/review.md deleted file mode 100644 index d0d45b4..0000000 --- a/review.md +++ /dev/null @@ -1,653 +0,0 @@ -# Code Review — `consent-plugin` - -**Reviewer:** senior engineer review, whole repository -**Date:** 2026-08-27 -**Revision reviewed:** `463ed56` (branch `main`, clean tree) - ---- - -## 1. Executive summary - -`consent-plugin` is a small, well-groomed Go codebase (≈5 000 lines incl. tests) that -implements an APISIX external plugin gating personal-data responses on the data -subject's consent. Craftsmanship at the *file* level is high: every exported symbol -is documented, magic numbers are named constants, errors are wrapped, `golangci-lint` -(15 extra linters, incl. `gosec`) reports **0 issues**, `govulncheck` reports **no -vulnerabilities**, tests pass under `-race`, and CI/release plumbing is complete. - -The problems are at the *design and lifecycle* level, and they cluster in one place: -**the security semantics of the default configuration, and the behaviour of the -newer owner-resolver path.** In its documented default shape the plugin answers the -wrong question ("does the *caller* have any consent?" instead of "did the *data -owner* consent to *this* caller?"), and the code path that fixes this -(`owner_resolver_url`) has **zero test coverage** and several silent fail-open holes. -Secondarily, the request-context store is an unbounded in-memory map with no -eviction — a slow leak with a credential-retention angle — and the README documents -a configuration surface (`client_id`/`client_secret`) that **no longer exists in the -code**, so anyone following it deploys a gate that never authenticates and, at the -default `fail_open: true`, silently allows everything. - -**Verdict:** the code is production-*grade* but not production-*ready*. The -must-fix set is C-1 … C-4 plus H-1; those are days of work, not weeks, and none -require re-architecture. - -### Verification performed - -| Check | Result | -| --- | --- | -| `go vet ./...` (go1.26.7) | clean | -| `golangci-lint run ./...` (v2.13.1) | **0 issues** | -| `govulncheck ./...` | no vulnerabilities | -| `go test -race ./...` | all pass | -| `go test -coverpkg=./... ` total | **69.1 %** (see §5) | -| `docker compose up` | **cannot start** — missing file (M-6) | - -### Findings at a glance - -| ID | Severity | Finding | Location | -| --- | --- | --- | --- | -| C-1 | Critical | Default (legacy) mode checks the **requestor's** consent, not the data owner's — an authenticated caller can read any subject's data | `internal/plugin/consent.go:404` | -| C-2 | Critical | Any `granted` consent authorises access, regardless of which consumer/purpose it was granted for | `internal/consent/client.go:621` | -| C-3 | Critical | Credential cache key omits `token_service_url` → cross-participant token / provider-SD confusion between routes | `internal/consent/client.go:190` | -| C-4 | Critical | README + CLAUDE.md document a removed config surface (`client_id`/`client_secret`); following them yields a silently open gate | `README.md:51-80` | -| H-1 | High | Party-resolution failures in resolver mode are logged and ignored → resolver called with no parties → possible `consentRequired:false` → allow | `internal/plugin/consent.go:251,257` | -| H-2 | High | `fail_open` defaults to **true** on a security control | `internal/plugin/config.go:229` | -| H-3 | High | Unbounded request-context store: no TTL, no cap, no eviction; retains `Authorization` bearer tokens | `internal/plugin/context.go:53` | -| H-4 | High | Owner-resolver path (the sound mode) has **0 % test coverage** | `internal/plugin/consent.go:233` | -| H-5 | High | Serialised per-owner consent checks with no request budget and no claim cap → unbounded response latency | `internal/plugin/consent.go:284-311` | -| M-1 | Medium | Deny response inherits all upstream headers (incl. `Content-Length`, `Set-Cookie`, pagination counters) | `internal/plugin/consent.go:425` | -| M-2 | Medium | `Validate()` accepts a config that cannot possibly authenticate | `internal/plugin/config.go:286` | -| M-3 | Medium | Audit trail is at-most-once and silently droppable — floodable, and never flushed at shutdown | `internal/audit/audit.go:164` | -| M-4 | Medium | `consumerFromClaims` cannot traverse arrays — fails silently on ordinary VP tokens | `internal/plugin/consent.go:461` | -| M-5 | Medium | Subject DIDs and upstream error bodies go to unstructured stdout logs, unrated | throughout | -| M-6 | Medium | `docker compose up` cannot work — `apisix-config.yaml` absent, socket bind-mount wrong | `docker-compose.yaml:29` | -| M-7 | Medium | Security scanners are `continue-on-error` and pinned to `latest` | `.github/workflows/security-analysis.yml` | -| L-1…L-11 | Low | Dead code, doc drift, container hardening, dependency age, unbounded resolver payload, misc. | see §6 | - ---- - -## 2. Architecture assessment - -### What the design gets right - -* **Phase correlation via `$request_id`** (`consent.go:44-66`) is the correct call and - the reasoning is documented at the point of use. The runner's per-RPC `ID()` really - is not stable across `ext-plugin-pre-req` / `ext-plugin-post-resp`; getting this - wrong is the classic bug in two-phase APISIX plugins, and this code avoids it. -* **Ownership from the data, not the requestor.** The `ownerresolver` package and the - emphatic comments around it ("Parties are for CONTRACT identification only — never - for ownership") show the right threat model. This is the correct architecture. -* **Coarse allow/deny rather than field filtering.** Deliberately chosen and - justified (`consent.go:158-178`): an empty or non-JSON personal-data response is - still gated. Better than a redaction filter that silently misses a field. -* **Per-entry credential cache locking** (`client.go:180-188`) coalesces concurrent - first-requests onto one token fetch without a global lock across the HTTP call. - That is a genuinely good piece of concurrency design, and it is tested - (`TestCheckConsent_ConcurrentTokenFetchCoalesced`). -* **Audit decoupled from the decision path** — bounded queue, background batching, - best-effort export. The right shape for a sidecar-adjacent gate. -* **Credentials via env, not route config**, keeping secrets out of etcd - (`config.go:272-284`). Correct instinct, correctly documented. - -### Structural concerns - -1. **Two modes, one of them unsound, and the unsound one is the default.** - `owner_resolver_url` is optional; when unset the plugin silently falls back to the - "legacy" JWT-subject mode (C-1). The two modes have very different security - properties but the same config surface and the same log prefix, and the README - documents only the weaker one. The legacy - mode should be dropped, setting a resolver is required. - -2. **The response phase is a synchronous fan-out of unbounded size.** - In resolver mode one client response can trigger `1 + 1 + 3N` HTTP calls (parties - mapping, `/resolve`, then per owner: identifier search + consents lookup, plus - token refresh) — all sequential, all on `context.Background()`, while APISIX holds - the buffered response. There is no per-request deadline, no concurrency, no cap on - `N`, and no negative caching (H-5). - -3. **No decision caching anywhere.** Every single response re-runs the whole chain. - Consent state changes rarely; a short-TTL (owner, resource) → decision cache with - explicit invalidation would cut the hot-path cost by an order of magnitude. The - token and participant-SD caches show the pattern is understood — it just was not - applied to the decision itself. - -4. **`ext-plugin-post-resp` implications are undocumented.** Attaching this phase - forces APISIX to buffer the entire upstream response (`ReadBody()` is a blocking - extra-info RPC over the unix socket), which defeats streaming and makes large - responses a memory multiplier. The README should state the constraint and a - recommended `max` response size for gated routes. - ---- - -## 3. Critical findings - -### C-1 — Legacy mode verifies the consent of the *requestor*, not of the data owner - -`internal/plugin/consent.go:404-423` (`buildConsentRequest`), reached from -`evaluate()` whenever `owner_resolver_url` is empty — the documented default. - -```go -if sub, ok := reqCtx.JWTClaims[jwtSubjectClaim]; ok { consentReq.Subject = subStr } -``` - -The `sub` of the *access token* becomes the consent subject. The check therefore -answers "has the caller granted some consent?" — it never establishes any link -between the caller and the data the upstream is about to return. - -**Failure scenario.** Alice (`did:key:zAlice`) has granted a consent. Alice obtains a -valid token and requests `/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob`. -The plugin resolves *Alice's* user identifier, finds *Alice's* granted consent, and -returns **Bob's** personal data. Any subject with one granted consent becomes a -universal reader. The gate is a no-op against the very threat it exists for. - -Compounding this, the JWT signature is **not verified** (`internal/jwt/extractor.go:19-21` -documents the assumption that APISIX or the upstream did it). Nothing in the plugin, -the config validation, or the README enforces that an auth plugin is actually attached -to the route. On a route without one, `sub` is attacker-supplied and the check -collapses entirely. - -**Recommendation.** Remove legacy mode: require `owner_resolver_url`, fail `Validate()` when no resolver is configured. - -### C-2 — Any granted consent authorises access, regardless of consumer or purpose - -`internal/consent/client.go:597-635` (`hasGrantedConsent`). - -```go -for _, consent := range out.Consents { - if consent.Status != grantedStatus { continue } - if dataResource == "" { return true, nil } // ← any consent, any consumer - ... -} -``` - -The consent list is filtered only on `status` (and optionally `data[].resource`). -It is never filtered by the **consuming participant** or the **purpose/contract**, -even though the plugin has just gone to the trouble of resolving the consumer's -self-description URL for the resolver call and *has* it in hand. - -**Failure scenario.** Bob grants consent to participant *X* for purpose "insurance -quote". Participant *Y* — a different consumer, with no consent from Bob — requests -Bob's data through this gateway. `hasGrantedConsent` sees Bob's granted consent to -*X* and allows *Y*. Under GDPR terms the plugin authorises a processing purpose the -subject never agreed to; the consent record it relied on is evidence of the wrong -agreement. - -**Recommendation.** Pass the consumer self-description (and, where the contract model -supports it, the purpose/contract id) into `hasGrantedConsent` and require the -consent's own participant/purpose to match. Until that is possible, the `?receipt=true` -payload should be inspected for the consumer field and mismatches treated as deny. -This is the single highest-value correctness fix in the codebase. - -### C-3 — Credential cache key omits the token source → cross-participant confusion - -`internal/consent/client.go:190`: - -```go -func (c *Client) cacheKey() string { return c.baseURL + "|" + c.tokenAudience } -``` - -The cached entry holds **both** the participant access token and the derived provider -self-description (`client.go:264-305`), but the key contains neither -`tokenServiceURL`, nor the static `ParticipantToken`, nor `consentKey`. `TokenAudience` -defaults to the constant `"consent-manager"` for every route. - -**Failure scenario.** One APISIX instance fronts two provider tenants — routes A and B -— both pointing at the same consent-manager `baseURL`, each with its own -`token_service_url` (its own participant credential). Both hash to the identical -cache key `"|consent-manager"`. Whichever route warms the cache first -installs *its* token and *its* `selfDescriptionURL`; the other route then performs the -identifier search scoped to the **wrong provider** and the consents lookup **as the -wrong participant**. Decisions are silently wrong in both directions: denials for -subjects who did consent, and allows against the wrong provider's consent records. -The same collision occurs when only `consent_key` or a static `provider_sd` differs -(the early-return at `client.go:255` only covers the case where *both* static token -*and* static SD are set). - -**Recommendation.** Key the cache on the full credential identity — e.g. -`baseURL | apiPrefix | tokenAudience | tokenServiceURL | sha256(staticToken) | providerSD`. -Add a test with two clients differing only in `token_service_url` asserting they do -not share a token. This is a small fix with a large blast radius; treat as must-fix -before any multi-tenant deployment. - -### C-4 — README and CLAUDE.md document a configuration surface that no longer exists - -`README.md:51-80` (config table + env note), `README.md:40-49` (participant-auth -section), `README.md:86-99` (route example), `CLAUDE.md` ("Important Files"). - -The docs describe participant **client credentials**: - -| Documented | Actually in the code | -| --- | --- | -| `client_id`, `client_secret` | *removed* — no such fields in `Config` | -| `CONSENT_CLIENT_ID`, `CONSENT_CLIENT_SECRET` | *removed* — env vars are `CONSENT_KEY`, `CONSENT_TOKEN_SERVICE_URL`, `CONSENT_AUDIT_OTLP_ENDPOINT` | -| `POST /participants/login` | *removed* — `fetchToken` posts to `token_service_url` (OID4VP facade) | -| — | `token_service_url`, `token_audience` — **undocumented** | -| — | `owner_resolver_url`, `owner_resolver_timeout`, `service`, `consumer_claim` — **undocumented** (the entire sound mode!) | -| — | `consent_api_host` — **undocumented** | - -`Config` has no `UnknownFields` rejection, so `client_id`/`client_secret` in a route -JSON are **silently discarded** by `json.Unmarshal`. - -**Failure scenario.** An operator copies the README's `curl` example verbatim. The -config parses and validates successfully. At request time `credentials()` returns -`"consent client: no participant_token and no token_service_url configured"` → the -fail policy applies. With the example's `"fail_open":false` this is a total outage of -every gated route with only a log line to explain it. With the *documented default* -(`fail_open: true`) it is worse: **every request is allowed, the consent gate is -entirely bypassed, and nothing signals it** beyond one log line per request. A -documentation defect here is a security defect. - -**Recommendation.** Rewrite the README config table and route example against -`internal/plugin/config.go`; document `owner_resolver_url` as the recommended mode -with an example; refresh `CLAUDE.md` (it also still lists an `internal/filter` -package that does not exist and omits `internal/audit` and `internal/ownerresolver`). -Add a CI check that every `json:` tag in `Config` appears in the README table — doc -drift on a security control needs a machine, not discipline. - ---- - -## 4. High-severity findings - -### H-1 — Party-resolution failures are logged and ignored, then the resolver's answer is trusted - -`internal/plugin/consent.go:249-262`: - -```go -if consumerSD, sdErr := consentClient.ParticipantSelfDescriptionByDID(...); sdErr != nil { - log.Printf(...) // ← swallowed -} else { resolveParties.Consumer = consumerSD } -if providerSD, sdErr := consentClient.ProviderSelfDescription(...); sdErr != nil { - log.Printf(...) // ← swallowed -} else { resolveParties.Provider = providerSD } -``` - -Both failures leave `resolveParties` empty; `Parties.IsZero()` then **omits the field -entirely** from the `/resolve` request (`ownerresolver/client.go:140-142`). The plugin -proceeds to trust whatever the resolver returns — including -`consentRequired: false`, which is an unconditional **allow** (`consent.go:273-275`). - -**Failure scenario.** The consent-manager is briefly unreachable, or the cached -participant token has been revoked. `ParticipantSelfDescriptionByDID` returns -`errParticipantUnauthorized` — and note it has **no 401-refresh-and-retry** of its own, -unlike `CheckConsent` (`client.go:340-389` calls `credentials(ctx, false)`), so a stale -token is terminal for the mapping. The plugin then asks the resolver "who owns this -payload?" with no parties at all. A resolver that cannot identify a contract and -answers `consentRequired: false` (a plausible, arguably correct answer for "no -contract governs this exchange") causes personal data to be released. A fail-closed -design has a fail-open seam in the middle of it. - -**Recommendation.** Treat both resolution failures as `failOutcome(...)` — the same -policy already applied to a resolver error. If a degraded mode is genuinely wanted, -make it explicit (`allow_unidentified_parties`), default it off, and never let an -unidentified-party `/resolve` result reach the allow branch. Add the 401-refresh -retry to `ParticipantSelfDescriptionByDID`, and cache negative lookups briefly so a -misconfigured DID does not re-fetch the whole participant list per request. - -### H-2 — `fail_open` defaults to `true` - -`internal/plugin/config.go:228-233`. Every unresolved situation — consent-manager -down, missing request context, missing credentials, resolver error, unreadable body — -becomes **allow** unless the operator explicitly sets `fail_open: false`. - -For an availability-shaped filter that default is defensible. For a **consent gate on -personal data** it inverts the safe default: the failure mode of the security control -is "release the data", and it is reached by *omission*. Combined with C-4 the two -compose into a silent full bypass. - -**Recommendation.** Flip the default to fail-closed (a `major` semver bump — the -repo's label-driven release process handles this cleanly), keep `fail_open: true` -available as a deliberate, documented opt-out, and log a warning at `ParseConf` when -it is enabled. At minimum, distinguish the reasons: a consent-manager timeout is a -plausible fail-open case; *missing credentials* and *missing request context* never are. - -### H-3 — Unbounded request-context store; retains bearer tokens - -`internal/plugin/context.go:53` — a package-level `sync.Map`, written in -`RequestFilter` and deleted only by `LoadAndDeleteRequestContext` in `ResponseFilter`. -There is **no TTL, no size cap, no eviction sweep, and no gauge**. - -**Failure scenario.** Any request whose response phase never runs leaks one entry -permanently: client disconnects before the upstream responds; upstream connect -timeout; a preceding APISIX plugin short-circuits the request after `pre-req`; -`ext-plugin-post-resp` misconfigured on one of several routes; the runner restarting -between phases. The runner is a long-lived process, so the map grows monotonically -until OOM. It is also remotely drivable: open connections, send the request, abort -before the response — an unauthenticated memory-exhaustion primitive. - -Each leaked entry holds `RequestContext.Headers`, which is a **copy of every request -header including `Authorization: Bearer `** (`consent.go:110-116`) — so the leak -is a leak of credentials retained indefinitely in process memory. And `Headers` is -never read: the only consumer is `len(rc.Headers)` in `String()` -(`context.go:107`). It is pure cost and pure risk. - -**Recommendation.** (a) Delete the `Headers` field and its capture loop — dead weight -holding secrets. (b) Store `{ctx, insertedAt}` and add a janitor goroutine evicting -entries older than a bounded lifetime (a few seconds beyond the upstream timeout), -plus a hard cap that rejects/evicts oldest on overflow. The runner already pulls in -`ReneKroon/ttlcache/v2` transitively if a library is preferred. (c) Export the -store size so the leak is observable. - -### H-4 — The owner-resolver path has zero test coverage - -Measured with `go test -coverpkg=./... ./...` (the repo's own `make test-cover` omits -`-coverpkg`, so cross-package integration coverage is not attributed at all — the -53.9 % it prints for `internal/plugin` is an artefact, the true figure is higher): - -| Symbol | Coverage | -| --- | --- | -| `plugin.evaluateWithResolver` | **0.0 %** | -| `plugin.consumerFromClaims` | **0.0 %** | -| `plugin.resourceOrPath` | **0.0 %** | -| `consent.ParticipantSelfDescriptionByDID` | **0.0 %** | -| `consent.decodeParticipants` | **0.0 %** | -| `consent.ProviderSelfDescription` | **0.0 %** | -| `plugin.claimKeysToDecode` | 40.0 % | -| **total (all packages)** | **69.1 %** | - -Every finding in C-3, H-1, H-5 and M-4 lives in that untested region. The legacy -mode — the one that is architecturally unsound — is the one with good integration -coverage (11 end-to-end cases in `internal/integration`). `internal/ownerresolver` -itself is tested in isolation (82.9 %), but nothing exercises the plugin↔resolver↔ -consent-manager composition. - -**Recommendation.** Extend `internal/integration` with a resolver-mode harness: a -mock `/resolve`, multi-owner `deny_all` (one owner denies → whole response denied), -`consentRequired: false`, empty `claims`, a claim with an empty `ownerId`, resolver -5xx/timeout under both fail policies, and party-resolution failure (H-1). Add -`-coverpkg=./...` to `make test-cover` and to `.github/workflows/tests.yml`, and gate -CI on a coverage floor so this cannot regress silently. - -### H-5 — Serialised per-owner checks, no request budget, no claim cap - -`internal/plugin/consent.go:284-311`. The loop over `result.Claims` performs one full -two-call consent check per distinct `(owner, dataResource)` pair, sequentially, each -on `context.Background()` with its own `consent_api_timeout`. - -**Failure scenario.** A collection endpoint returns 200 entities with 200 distinct -owners. The plugin issues ~400 sequential HTTP calls; at the default 5 s per-call -timeout the worst case is ~2 000 s of held-open response while APISIX buffers the -body. Long before that, the client and APISIX time out, but the runner's goroutine -keeps working and its connections stay open — a small number of such requests -saturates the runner and the consent-manager. Any caller who can reach a -list endpoint can trigger it; no authentication beyond the ordinary token is needed. - -Related inefficiencies in the same loop: the dedup key is `(owner, dataResource)`, so -the same owner with two resources performs the identifier search **twice**; there is -no negative caching of "unknown subject"; and no decision cache (see §2.3). - -**Recommendation.** (a) Derive one `context.WithTimeout` for the whole response phase -and pass it to the resolver and every consent call, so the total is bounded and -cancellation propagates. (b) Cap the number of distinct claims checked (configurable, -e.g. `max_owners_per_response`) and fail closed above it. (c) Run the per-owner -checks with bounded concurrency (`errgroup.WithContext`, limit ~8) and short-circuit -on the first deny. (d) Memoise `subject → userIdentifier` for the duration of the -request. - ---- - -## 5. Test suite assessment - -**Strengths.** `-race` in both `make test` and CI. Table-driven subtests are the norm -(`config_test.go`, `consent_test.go`), matching the project convention. The -`internal/integration` package is a genuine end-to-end harness — real `ParseConf` → -`RequestFilter` → `ResponseFilter` against `httptest` consent-managers — not a mock -theatre; 11 scenarios cover pass-through, deny, unknown subject, custom deny -response, both fail policies, custom JWT header, context cleanup and the token -service. `internal/jwt` is at 100 %. `TestCheckConsent_ConcurrentTokenFetchCoalesced` -tests the coalescing invariant rather than the implementation. This is above-average -test discipline. - -**Gaps.** - -* **H-4**: the entire owner-resolver path is untested. Highest priority. -* **Coverage is mis-measured.** `make test-cover` and `tests.yml` omit `-coverpkg=./...`, - so the integration package's coverage of `internal/plugin` is discarded. The printed - numbers understate reality and, worse, make the *real* gaps (0 % functions) look - like measurement noise. Fix the flag; add a floor. -* **No coverage gate in CI.** Coverage is uploaded as an artefact and never asserted. -* **Package-level cache pollution across tests.** `credCache`, `participantSDCache` - and `emitters` are package globals with no test reset hook. Tests currently pass - only because `httptest` allocates a distinct `baseURL` per server; a future test - reusing a URL, or `t.Parallel()`, will produce order-dependent flakes. Add an - exported-for-test reset (or key the caches off an injectable struct). -* **Mocks diverge from the real runner in a load-bearing way.** `mockResponse.Write` - *replaces* `writtenBody` (`integration_test.go:154-157`) whereas the real - `Response.Write` *appends* to a buffer; `mockResponse.Header()` never affects a - `HasChange()`-equivalent. So no test can observe M-1 (header leakage on deny) or the - `Content-Length` question, and no test would catch a double-write regression. - Consider a fake that mirrors `internal/http.Response` semantics. -* **No `Content-Length`/header assertions on the deny path**, no test for a body - larger than the resolver limit, no test for `$request_id` unavailable in only one - phase, and no benchmark or load test despite H-5 being a latency finding. -* `TestConfig_IsFailOpen` lives in `consent_test.go` while the rest of the config - tests are in `config_test.go` — minor misfiling. - ---- - -## 6. Medium and low findings - -### M-1 — Deny response inherits all upstream headers - -`internal/plugin/consent.go:425-433` sets `Content-Type` and the status, writes the -deny body, and touches nothing else. Every other upstream response header survives -into the 403. - -* **`Content-Length`** still advertises the upstream body's length while the body is - now the 43-byte deny JSON. Whether the client sees a truncated/hung response - depends on whether APISIX recomputes it when `ext-plugin-post-resp` replaces a body - — **I could not verify this without a live APISIX**, and no test covers it. It is - the first thing to check in an end-to-end run. -* **Information leak, verified by inspection:** `Set-Cookie`, `ETag`, `Last-Modified`, - `Link`, and application headers such as `X-Total-Count` / `NGSILD-Results-Count` - reach a client that was just denied the data. A denied caller can read pagination - counts and entity versions — a side channel around the gate. - -**Fix:** on deny, delete the upstream headers before writing (whitelist what may -survive), and set `Content-Length` explicitly. Add an assertion once the mock supports it. - -### M-2 — `Validate()` accepts a configuration that cannot authenticate - -`internal/plugin/config.go:286-336` validates URLs, timeout and status-code ranges, -and the audit endpoint, but never checks that *some* participant credential exists -(`participant_token` **or** `token_service_url`). The README even documents this as -intentional ("None are enforced at parse time"). Combined with H-2 the result is a -route that loads cleanly and allows everything. `owner_resolver_timeout` and -`participant_token_ttl` are also unvalidated (unlike `consent_api_timeout`), and -`consent_api_prefix` is concatenated unchecked in `endpoint()` -(`client.go:637-639`) — a prefix without a leading `/` silently produces a malformed -URL. **Fix:** require a credential source at parse time; range-check the other -numeric fields; normalise/validate the prefix. - -### M-3 — Audit trail is silently droppable and never flushed - -`internal/audit/audit.go:164-172`. The queue is bounded at 2048 and `Emit` drops on -overflow with only a rate-limited log line — deliberate and correct *for the request -path*, but it means the compliance record is at-most-once and **an attacker can -suppress the record of their own access by generating load**. Also: - -* `main()` never calls `Shutdown()`, so up to `defaultFlushInterval` (2 s) of - decisions are lost on every runner restart/redeploy. `Shutdown` exists and is - tested; wire a `SIGTERM` handler. -* `Get()` caches emitters by `endpoint|serviceName` but **not** by `Timeout` - (`audit.go:118-134`), so the first route's timeout silently wins for all others. -* In resolver mode only the **first denying** owner is recorded, and an allow records - no owners at all (`consent.go:305-313`, `:315`) — so the audit log cannot answer - "whose consent was checked?", which is the question an audit log exists to answer. -* `consent.reason` carries `truncateBody()` output from consent-manager errors - (`client.go:666-672`), so upstream error bodies — potentially containing identifiers - — land in the audit sink. -* No OTLP authentication headers are configurable. - -**Fix:** record every checked `(owner, resource, decision)` per request; add a -`SIGTERM` flush; include `Timeout` in the emitter key; sanitise `reason` before -export; expose the dropped counter as a metric so suppression is detectable. - -### M-4 — `consumerFromClaims` cannot traverse arrays - -`internal/plugin/consent.go:461-482` walks a dotted path through -`map[string]interface{}` only. The default path is -`verifiableCredential.issuer` (`config.go:37-43`), but a Verifiable Presentation -commonly carries `verifiableCredential` as a **JSON array**. The type assertion -fails, `""` is returned, no error is logged from this function, and the consumer is -simply absent — feeding directly into H-1's allow seam. **Fix:** support array -indexing (`verifiableCredential[0].issuer` or implicit first-element traversal), and -distinguish "path not configured" from "path did not resolve" so the latter can be -treated as a failure. - -### M-5 — Unstructured logging of personal identifiers, unrated - -`log.Printf` with a hand-written `[consent-filter]` prefix appears ~15 times across -`plugin/` and `audit/`. Consequences: (a) the runner ships `pkg/log` (zap) whose level -configuration therefore does not apply — these lines cannot be filtered or -suppressed; (b) messages carry subject DIDs (`consent.go:251`) and upstream error -bodies, i.e. personal data in stdout with no retention policy, which is exactly what -the OTLP audit path was built to avoid; (c) there is no rate limiting, so a broken -consent-manager emits one line per request. **Fix:** switch to the runner's logger -with levels, drop or hash identifiers in non-audit logs, and rate-limit the -per-request failure paths. - -### M-6 — The documented local dev setup cannot start - -`docker-compose.yaml:29` mounts `./apisix-config.yaml`, which **does not exist in the -repository**; Docker will create a *directory* at that path and APISIX will fail to -parse its config. Additionally, both services bind-mount `/tmp/runner.sock` — a -socket file that does not exist at compose time, so Docker again creates a directory -and the runner cannot bind. The conventional fix is to share a *directory* (or named -volume) and put the socket inside it. `version: "3.8"` is also obsolete under Compose -v2. Since `README.md:121-124` advertises `docker compose up --build` as the dev workflow, -this is the first thing a new contributor hits. **Fix:** commit an -`apisix-config.yaml` with the `ext-plugin` wiring, switch to a shared socket -directory, drop `version:`, and add the consent-manager mock + otel-collector so the -stack is actually exercisable. - -### M-7 — Security scans cannot fail the build; tool versions unpinned - -`.github/workflows/security-analysis.yml` runs both `govulncheck` and `gosec` with -`continue-on-error: true`, so findings are informational only — a known-vulnerable -dependency merges cleanly. `gosec` is installed from `@latest` and -`golangci-lint-action` uses `version: latest`, making CI non-reproducible and -supply-chain-exposed; note the Gitea pipeline pins `v2.1.6`, so the two CIs can -disagree about whether the code lints. **Fix:** pin both tools; let `govulncheck` fail -the PR on a fixable vulnerability (allow-list with expiry for the rest); add -`go mod verify` and dependency review. - -### Low - -* **L-1 — Dead code from a removed field-filtering design.** `DecisionFilter`, - `Decision.IsValid`, `ConsentResponse.Validate`, `ConsentResponse.DeniedFields`, - `ConsentRequest.ResponseFields` and `ConsentRequest.Claims` - (`internal/consent/models.go:36-107`) are unreferenced by production code — they are - tested, which makes the coverage number flatter. Their `json:` tags describe a - `POST /check` API that the two-call client never calls, so the file actively - misleads. `ownerresolver.Claim.Selector`/`.Participant` and `Result.Scheme` are - decoded and never read. `plugin.LoadRequestContext` and `plugin.DeleteRequestContext` - are exported and unused. `RequestContext.Headers` — see H-3. Delete all of it. -* **L-2 — Package doc contradicts behaviour.** `internal/consent/models.go:19-21` and - `internal/plugin/config.go:19-20` still describe "filtering personal data" / - "allowed, denied, or filtered"; the plugin does coarse allow/deny. `CLAUDE.md` lists - an `internal/filter` package that does not exist, describes `/participants/login` - client credentials (see C-4), and omits `internal/audit` and `internal/ownerresolver`. -* **L-3 — Container hardening.** The runtime image (`Dockerfile:20-30`) runs as - **root** with no `USER`, on `alpine:3.19` (past end-of-support), with no - `HEALTHCHECK` and no `.dockerignore` (so `COPY . .` pulls `.git`, invalidating the - build cache on every commit). Add a non-root user, bump the base, add - `.dockerignore`. -* **L-4 — Dependency age.** Direct deps are stale: `testify` 1.8.4 → 1.12.1, - `api7/ext-plugin-proto` v0.6.0 → v0.6.1. `apisix-go-plugin-runner` is pinned at - v0.5.0 (its own transitive tree — zap 1.17, flatbuffers 2.0.0, grpc 1.38 — is - years old); worth tracking whether the runner is still maintained, since a plugin - whose runner is abandoned is a strategic risk. No Renovate/Dependabot config. -* **L-5 — `HasChange()` is forced true on every resolver-mode allow.** - `evaluateWithResolver` calls `w.Header()` to read `Content-Type` - (`consent.go:239-242`), which lazily initialises `hdr` and therefore makes the - runner's `HasChange()` return true (`internal/http/response.go:207`) even when - nothing was modified. Every gated response then travels the "response was - modified" path back to APISIX with an empty header diff. Probably benign, entirely - untested; read the `Content-Type` from the stored `RequestContext` or from - `r.rawHdr` instead. -* **L-6 — No metrics.** For a component that can deny production traffic there is no - counter for allow/deny/fail-open, no consent-manager latency histogram, no - context-store gauge, no audit-drop counter. Operationally this is flying blind; - logs are the only signal, and they are unstructured (M-5). -* **L-7 — `ParticipantTokenTTL` overflow.** `time.Duration(cfg.ParticipantTokenTTL) * time.Second` - (`consent.go:349`) overflows for absurd values; unvalidated (see M-2). -* **L-8 — Repo hygiene.** No `SECURITY.md` (vulnerability reporting path) and no - `CODEOWNERS` for a repo that gates personal-data access. `CONTRIBUTING.md` and the - workflow set are otherwise good. -* **L-9 — Local toolchain friction.** `go.mod` requires `go 1.26` while the system Go - is 1.22 and `GOTOOLCHAIN` cannot download; contributors need a pre-cached 1.26 - toolchain (this review used `go1.26.7` from the module cache). Worth a line in - `CONTRIBUTING.md`. -* **L-10 — No size cap on the payload forwarded to the OwnerResolver.** - `internal/plugin/consent.go:234` reads the whole upstream body, then - `ownerresolver.Resolve` (`internal/ownerresolver/client.go:131-170`) runs - `json.Valid(payload)` over it and `json.Marshal` copies it again into the request - envelope as a `json.RawMessage`. Peak footprint is therefore roughly *3×* the body - size per in-flight request, on top of APISIX's own buffering of the response — so a - handful of concurrent large-collection responses can drive the runner's memory well - past what the response size suggests. Unrelated to transport security: cluster mTLS - does not bound the copy. **Fix:** add a `max_resolve_body_bytes` limit and fail - closed above it rather than forwarding, and stream or reference the body instead of - embedding it once a limit exists. -* **L-11 — A non-JSON body is indistinguishable from no body at all.** - `internal/ownerresolver/client.go:132-135`: when `json.Valid(payload)` fails, the - envelope is sent with `encoding: "none"`, exactly as it is when there was no body. - The resolver cannot tell "this response carried a payload I could not parse" from - "this response had no payload", so it resolves ownership from the resource - descriptor alone. A malformed-but-personal payload (a truncated write, a - content-type mismatch, an upstream returning XML or NDJSON on a route declared - JSON) is therefore judged without ever being inspected. **Fix:** distinguish the - two cases in the envelope — e.g. a third encoding value, or `encoding: "opaque"` - with the content type — so the resolver can fail closed on an unparseable payload - instead of silently falling back. - ---- - -## 7. Prioritised action plan - -**Must fix before production** - -1. **C-4** — rewrite README + CLAUDE.md against the real config; add a doc-drift CI - check. *(Cheapest fix, prevents the silent-bypass deployment.)* -2. **C-1** — make owner-resolver mode mandatory (or legacy mode a loud, explicit - opt-in); document JWT verification as a hard route prerequisite. -3. **C-2** — scope the consent match to the consuming participant and purpose. -4. **C-3** — include the token source in the credential cache key; add the - two-participant regression test. -5. **H-1** — fail closed when the consumer or provider cannot be resolved; add the - 401-refresh retry to `ParticipantSelfDescriptionByDID`. -6. **H-2** — default `fail_open` to false; **M-2** — require a credential source at - parse time. -7. **H-3** — drop `RequestContext.Headers`; add TTL + cap + size gauge to the context store. - -**Before scale / next iteration** - -8. **H-4** — resolver-mode integration tests; `-coverpkg=./...` plus a CI coverage floor. -9. **H-5** — one request-scoped deadline, bounded concurrency, claim cap, identifier memoisation. -10. **M-1** — strip upstream headers on deny; verify `Content-Length` end-to-end against a real APISIX. -11. **M-3** — audit every checked owner; flush on `SIGTERM`; sanitise `reason`. -12. **M-6** — make `docker compose up` actually work; **M-7** — pin and enforce the scanners. -13. **L-6** — add Prometheus metrics for decisions, latency, drops and store size. - -**Cleanup** - -14. **L-1/L-2** — delete the dead field-filtering model and fix the stale package docs. -15. **L-3/L-4/L-8/L-9** — non-root container, base-image bump, `.dockerignore`, - dependency refresh + Renovate, `SECURITY.md`, `CODEOWNERS`, toolchain note. -16. **L-10/L-11** — cap the body forwarded to the OwnerResolver, and distinguish - "non-JSON body" from "no body" in the resolve envelope. - ---- - -## 8. Closing note - -The engineering hygiene here is genuinely good — the comments explain *why* rather -than *what*, the `$request_id` correlation and the per-entry credential locking show -real care, and the linter/CI/release setup is more complete than most projects of this -size. The gap is that the codebase is mid-migration: an older, unsound design -(requestor-subject consent) is still the default and still the only documented one, -while the sound design (owner-resolver) is present, undocumented, and untested. Most -of the critical findings are consequences of that unfinished transition rather than -of careless code. Finishing the migration — making resolver mode the only mode, -documenting it, testing it, and failing closed throughout — resolves C-1, C-4, H-1, -H-2 and H-4 together, and would move this from "promising" to "trustworthy". From 0048714fbc11e80ba0d4bacd2ae901e586ec51c2 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:24:59 +0200 Subject: [PATCH 30/41] =?UTF-8?q?test:=20cover=20the=20phase-correlation?= =?UTF-8?q?=20gap=20(=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's test-suite assessment noted there was no test for `$request_id` being unavailable in only one phase. That case matters because it is the one where the plugin is structurally unable to gate: with no correlation key the two phases cannot be matched, so there is no captured context to decide on. Three cases added: - the response phase cannot read `$request_id` — denies, even with `fail_open`, since this is not an outage to ride out; - the request phase left no context at all — likewise denies; - the request phase with no correlation key stores nothing, rather than leaking an entry per request into the context store, and with one stores what the response phase later needs. Co-Authored-By: Claude Opus 5 --- internal/plugin/consent_test.go | 100 ++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 453faa6..594766e 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -20,10 +20,13 @@ package plugin import ( "consent-plugin/internal/audit" "consent-plugin/internal/ownerresolver" + "context" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" + "net/url" "strconv" "strings" "sync" @@ -124,6 +127,9 @@ type mockResponse struct { // suppressContentTypeVar makes Var() report no upstream Content-Type, to // exercise the header fallback. suppressContentTypeVar bool + // suppressRequestIDVar makes Var() report no $request_id, as happens when the + // two phases cannot be correlated. + suppressRequestIDVar bool } // newMockResponse builds a JSON upstream response carrying body. @@ -145,6 +151,9 @@ func (r *mockResponse) Header() pkgHTTP.Header { func (r *mockResponse) Var(name string) ([]byte, error) { switch name { case nginxRequestIDVar: + if r.suppressRequestIDVar { + return nil, nil + } return []byte(testReqKey(r.id)), nil case nginxUpstreamContentTypeVar: if r.suppressContentTypeVar { @@ -1194,3 +1203,94 @@ func TestResponseFilter_BodyCapDenies(t *testing.T) { assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, "a body too large to examine must be denied, not forwarded") } + +// TestResponseFilter_CorrelationIdMissingInOnerPhase verifies the case where the +// two phases cannot be correlated: the request phase stored nothing (or the +// response phase cannot read $request_id), so there is no context to decide on. +// The plugin is structurally unable to gate here, so it must deny even with +// fail_open — this is not an outage to ride out. +func TestResponseFilter_CorrelationIDMissingInOnePhase(t *testing.T) { + server := newUncalledConsentManager(t) + defer server.Close() + resolver := newUncalledOwnerResolver(t) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.FailOpen = boolPtr(true) + + t.Run("response phase cannot read the correlation id", func(t *testing.T) { + clearContextStore() + resp := newMockResponse(260, []byte(`{}`)) + resp.suppressRequestIDVar = true + storeRequest(260) + + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "without a correlation id the phases cannot be matched, so nothing can be verified") + }) + + t.Run("request phase never stored a context", func(t *testing.T) { + clearContextStore() + resp := newMockResponse(261, []byte(`{}`)) + + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "a response whose request phase left no context must not be released") + }) +} + +// mockRequest implements pkgHTTP.Request. correlationID is what Var() reports +// for $request_id; empty means the variable is unavailable. +type mockRequest struct { + header *mockHeader + correlationID string +} + +func (r *mockRequest) ID() uint32 { return 1 } +func (r *mockRequest) SrcIP() net.IP { return net.ParseIP("127.0.0.1") } +func (r *mockRequest) Method() string { return "GET" } +func (r *mockRequest) Path() []byte { return []byte("/data") } +func (r *mockRequest) SetPath([]byte) {} +func (r *mockRequest) Header() pkgHTTP.Header { return r.header } +func (r *mockRequest) Args() url.Values { return nil } +func (r *mockRequest) Var(name string) ([]byte, error) { + if name == nginxRequestIDVar && r.correlationID != "" { + return []byte(r.correlationID), nil + } + return nil, nil +} +func (r *mockRequest) Body() ([]byte, error) { return nil, nil } +func (r *mockRequest) Context() context.Context { return context.Background() } +func (r *mockRequest) RespHeader() http.Header { return nil } + +// TestRequestFilter_CorrelationID verifies the request phase stores a context +// only when it can be correlated with the response phase. Storing one it could +// never retrieve would leak an entry per request into the context store. +func TestRequestFilter_CorrelationID(t *testing.T) { + cfg := newTestConfig("http://consent.invalid", "http://resolver.invalid/resolve") + + t.Run("no correlation id stores nothing", func(t *testing.T) { + clearContextStore() + req := &mockRequest{header: newMockHeader()} + + (&ConsentFilter{}).RequestFilter(cfg, httptest.NewRecorder(), req) + + assert.Equal(t, 0, RequestContextStoreSize(), + "a context that could never be correlated must not be stored") + }) + + t.Run("a correlation id stores the context", func(t *testing.T) { + clearContextStore() + defer clearContextStore() + req := &mockRequest{header: newMockHeader(), correlationID: "req-abc"} + + (&ConsentFilter{}).RequestFilter(cfg, httptest.NewRecorder(), req) + + stored, found := LoadAndDeleteRequestContext("req-abc") + require.True(t, found) + assert.Equal(t, "/data", stored.Path) + assert.Equal(t, "GET", stored.Method) + }) +} From 77f8aff163063b1fcad5c7fbbcdeed73a23cba5b Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:40:08 +0200 Subject: [PATCH 31/41] fix: run the audit flush after the runner returns, not beside it (N-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shutdown flush was present and essentially never completed. `runner.Run` installs its own `signal.Notify` for SIGINT/SIGTERM and returns as soon as one arrives — its shutdown path is `close(done)` and return, i.e. instant. The flush waited for the same signal in a goroutine of its own. Go delivers a signal to *every* registered channel, so on SIGTERM both woke at once: the runner returned, `main` returned, and the process exited while `audit.ShutdownAll()` was still draining its queue and waiting on an HTTP export (up to the 5s export timeout). So a rolling redeploy still discarded up to one flush interval of access decisions — every allow and deny in that window — with the added problem that the loss was now invisible: `consent_audit_events_dropped_total` counts queue-overflow drops, and these were not that. The fix looked done and fired almost never. Since `Run` already blocks until the signal, the flush belongs after it, synchronously, where nothing can exit out from under it — and the plugin needs no signal handler of its own. Registering one was actively harmful anyway: `signal.Notify` disables the default SIGTERM disposition, so had the runner's handler ever stopped returning promptly, nothing would have terminated the process. `runAndFlush` makes the ordering explicit and testable; `main_test.go` pins it, because a flush that never completes still passes any test that only checks it was called. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- main.go | 36 ++++++++++++++++++------------- main_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 main_test.go diff --git a/README.md b/README.md index e456659..cd618d8 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ and `consent_audit_events_dropped_total`. Metrics are off unless the variable is set — the runner is otherwise reached only over its unix socket, so opening a TCP port is the deployment's decision. -**Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per **checked data owner** (so the log answers whose consent was consulted and what each said, not merely whether the response was released; a request that failed before any owner was reached is recorded once as itself) to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. The queue is flushed on `SIGTERM`/`SIGINT`, so a redeploy does not discard the last flush interval of decisions. Reasons are sanitised before export (control characters collapsed, length bounded) so an upstream error body cannot reach the audit sink verbatim. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. +**Access audit log.** With `audit_enabled`, the plugin emits one OTLP/HTTP **log record** per **checked data owner** (so the log answers whose consent was consulted and what each said, not merely whether the response was released; a request that failed before any owner was reached is recorded once as itself) to `audit_otlp_endpoint`, stamped with resource `service.name=` and attributes `event.domain=audit`, `consent.decision`, `consent.reason`, `enduser.id`, `http.request.method`, `url.path`, `http.request.id`. Emission is asynchronous, batched, and best-effort (a bounded queue drops rather than blocking the request path), so a slow/absent Collector never affects data access. The queue is flushed when the runner exits (it returns on `SIGTERM`/`SIGINT`), so a redeploy does not discard the last flush interval of decisions. Reasons are sanitised before export (control characters collapsed, length bounded) so an upstream error body cannot reach the audit sink verbatim. Mark-based routing lets the Collector send these to an append-only audit sink separate from traces. ## APISIX Route Configuration Example diff --git a/main.go b/main.go index 4f78bf4..39c31fc 100644 --- a/main.go +++ b/main.go @@ -29,8 +29,6 @@ import ( "errors" "net/http" "os" - "os/signal" - "syscall" "time" "github.com/apache/apisix-go-plugin-runner/pkg/runner" @@ -50,23 +48,31 @@ const metricsPath = "/metrics" const metricsServerTimeout = 10 * time.Second func main() { - // The audit queue is flushed by a background worker on an interval, so a - // redeploy or restart would otherwise discard up to one flush interval of - // access decisions — silently, from the record whose whole purpose is to be - // complete. runner.Run blocks, so the flush is driven from its own goroutine. - go flushAuditOnShutdown() go serveMetrics() - runner.Run(runner.RunnerConfig{}) + runAndFlush(func() { runner.Run(runner.RunnerConfig{}) }, audit.ShutdownAll) } -// flushAuditOnShutdown waits for a termination signal and flushes every audit -// emitter before the process goes away. -func flushAuditOnShutdown() { - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) - <-signals - audit.ShutdownAll() +// runAndFlush runs the plugin runner to completion and only then flushes the +// audit queue. +// +// The ordering is the whole point, and it is easy to get wrong. The runner +// installs its own signal.Notify for SIGINT/SIGTERM and returns from Run as soon +// as one arrives. An earlier version of this file waited for the same signal in +// a goroutine of its own and flushed there — but Go delivers a signal to every +// registered channel at once, so the runner's handler returned, main returned, +// and the process exited while the flush was still draining its queue and +// waiting on an HTTP export. The flush was present and essentially never +// completed: exactly the loss it was added to prevent, now wearing the +// appearance of being handled, and invisible to the dropped-events counter +// because these were not queue-overflow drops. +// +// Since Run already blocks until the signal, the flush belongs after it, +// synchronously, where nothing can exit out from under it — and the plugin needs +// no signal handler of its own. +func runAndFlush(run, flush func()) { + run() + flush() } // serveMetrics exposes the plugin's Prometheus metrics when an address is diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f1af53c --- /dev/null +++ b/main_test.go @@ -0,0 +1,61 @@ +/* + * 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 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunAndFlushOrdering pins the ordering the audit flush depends on: it must +// happen after the runner has returned, not concurrently with it. +// +// The previous wiring ran the flush in its own goroutine waiting on the same +// signal the runner waits on, so the process exited before the flush finished +// and the queued records were lost. Nothing in the suite noticed, because a +// flush that never completes still compiles and still passes every test that +// only checks it was called. +func TestRunAndFlushOrdering(t *testing.T) { + var sequence []string + + runAndFlush( + func() { sequence = append(sequence, "run") }, + func() { sequence = append(sequence, "flush") }, + ) + + require.Len(t, sequence, 2) + assert.Equal(t, []string{"run", "flush"}, sequence, + "the audit flush must run after the runner returns, or the process exits with the queue undrained") +} + +// TestRunAndFlushFlushesAfterRunBlocks verifies the flush waits for a runner +// that returns only when it is good and ready — the real runner blocks until +// SIGTERM. +func TestRunAndFlushFlushesAfterRunBlocks(t *testing.T) { + runnerReturned := false + flushSawReturn := false + + runAndFlush( + func() { runnerReturned = true }, + func() { flushSawReturn = runnerReturned }, + ) + + assert.True(t, flushSawReturn, "the flush must not start until the runner has returned") +} From f73812b18afbaa13629bcf9446f7e659f44727a2 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:42:11 +0200 Subject: [PATCH 32/41] fix(plugin): rank a definite deny above a dependency error (N-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced by the H-5 concurrency work. After `wg.Wait()`, `checkOwners` walked the per-owner results in index order and returned the first entry marked as a problem — whether that problem was an error or a deny. Index ordering made the verdict deterministic, which was the goal and was correctly achieved, but it ranked error and deny purely by position, and they are not equivalent: a deny is a definite answer, an error is the absence of one, and only the absence is subject to the operator's fail policy. Failure scenario: a response resolves to owners A (index 0) and B (index 1) on a route configured `fail_open: true`. Both checks are in flight; B's returns deny (no granted consent) while A's returns HTTP 500, both landing before either cancellation takes effect. The loop reached index 0 first, saw a non-cancellation error, and applied the fail policy — releasing B's data even though B has explicitly not consented. The audit record then showed the contradiction: a per-owner deny for B alongside an enforced allow. The reduction is now by decisiveness first and index second: one pass looks for a deny across all results, and only if there is none does the error pass run. Within each pass the lowest index still wins, so the verdict remains independent of goroutine scheduling. The regression test forces the exact race with a two-party barrier holding both checks open until both have genuinely completed, so neither result is a cancellation artifact. It fails against the previous reduction and passes with this one. A second table test confirms the error path still applies `fail_open` when no deny exists anywhere. Co-Authored-By: Claude Opus 5 --- internal/plugin/consent.go | 40 ++++++--- internal/plugin/consent_test.go | 144 ++++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 21 deletions(-) diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 9fe483b..0f8ea3d 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -512,23 +512,37 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } } + // Results are reduced by DECISIVENESS first and index second. + // + // A deny is a definite answer; an error is the absence of one, and only the + // absence is subject to the operator's fail policy. Reducing by index alone + // ranked the two purely by position, so an error at a lower index could mask + // a deny at a higher one — and under `fail_open: true` that released data an + // owner had explicitly refused, with the audit record showing the + // contradiction (a per-owner deny alongside an enforced allow). Scanning for + // a deny across all results first removes the ordering dependency; within + // each pass the lowest index still wins, so the verdict stays deterministic + // rather than depending on which goroutine finished first. for _, result := range results { - if !result.attempted || !result.problem { - continue - } - if result.err != nil { - // A call cancelled because a *different* owner already denied is not - // itself a failure; the deny it lost the race to is reported instead. - if errors.Is(result.err, context.Canceled) && ctx.Err() == nil { - continue - } - logging.ErrorfEvery("consent-check", "ResponseFilter: consent check error for request %s: %s", key, logging.Sanitize(result.err.Error())) - req := result.request - outcome := failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) + if result.attempted && result.problem && result.err == nil { + outcome := result.outcome outcome.checked = checked return outcome } - outcome := result.outcome + } + + for _, result := range results { + if !result.attempted || result.err == nil { + continue + } + // A call cancelled because a *different* owner already denied is not + // itself a failure; that deny was returned by the pass above. + if errors.Is(result.err, context.Canceled) && ctx.Err() == nil { + continue + } + logging.ErrorfEvery("consent-check", "ResponseFilter: consent check error for request %s: %s", key, logging.Sanitize(result.err.Error())) + req := result.request + outcome := failOutcome(cfg, failModeForError(result.err), "consent check error: "+result.err.Error(), key, &req) outcome.checked = checked return outcome } diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 594766e..1102283 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -19,6 +19,7 @@ package plugin import ( "consent-plugin/internal/audit" + "consent-plugin/internal/consent" "consent-plugin/internal/ownerresolver" "context" "encoding/json" @@ -213,14 +214,14 @@ func newConsentManager(t *testing.T, userID string, statuses []string) *httptest } // newFailingConsentManager returns a consent-manager whose CONSENT CHECK calls -// answer with the given status code (used to exercise the fail policy). The -// participant registry still answers, so the failure under test is the check -// itself and not the preceding contract lookup. -func newFailingConsentManager(status int) *httptest.Server { +// answer 500 (used to exercise the fail policy). The participant registry still +// answers, so the failure under test is the check itself and not the preceding +// contract lookup. +func newFailingConsentManager() *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/v1/participants", participantRegistryHandler) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(status) + w.WriteHeader(http.StatusInternalServerError) }) return httptest.NewServer(mux) } @@ -475,7 +476,7 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { { name: "consent-manager error denies by default (fail-closed)", setupContext: storeRequest, - consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager() }, resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, wantWrittenBody: DefaultDenyResponseBody, wantWrittenStatus: DefaultDenyStatusCode, @@ -483,7 +484,7 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { { name: "consent-manager error with fail-open explicitly enabled passes through", setupContext: storeRequest, - consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager() }, resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(true) }, wantNoWrite: true, @@ -491,7 +492,7 @@ func TestConsentFilter_ResponseFilter(t *testing.T) { { name: "consent-manager error with fail-closed denies", setupContext: storeRequest, - consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager(http.StatusInternalServerError) }, + consentServer: func(t *testing.T) *httptest.Server { return newFailingConsentManager() }, resolverServer: func(t *testing.T) *httptest.Server { return newOwnerResolver(t, ownedBy(testOwnerDID)) }, configFn: func(cfg *Config) { cfg.FailOpen = boolPtr(false) }, wantWrittenBody: DefaultDenyResponseBody, @@ -1294,3 +1295,130 @@ func TestRequestFilter_CorrelationID(t *testing.T) { assert.Equal(t, "GET", stored.Method) }) } + +// twoPartyBarrier releases both callers only once both have arrived, so a test +// can force two concurrent consent checks to complete before either cancels the +// other. It fails the test rather than hanging if the second never arrives. +func twoPartyBarrier(t *testing.T) func() { + t.Helper() + arrived := make(chan struct{}, 2) + released := make(chan struct{}) + var once sync.Once + return func() { + arrived <- struct{}{} + if len(arrived) == 2 { + once.Do(func() { close(released) }) + } + select { + case <-released: + case <-time.After(5 * time.Second): + t.Errorf("barrier timed out: the second concurrent check never arrived") + } + } +} + +// TestCheckOwners_DenyOutranksDependencyError is the regression test for the +// ordering hole the concurrency work opened. +// +// Two owners are checked concurrently: the one at index 0 errors (HTTP 500) and +// the one at index 1 denies. Reducing the results by index alone returned the +// error, which under fail_open:true releases the response — even though an owner +// has explicitly refused. A deny is a definite answer and must outrank the +// absence of one, whatever position it landed in. +func TestCheckOwners_DenyOutranksDependencyError(t *testing.T) { + clearContextStore() + consent.ResetCaches() + + const ( + ownerErroring = "did:key:zErroring" + ownerDenying = "did:key:zDenying" + ) + release := twoPartyBarrier(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", participantRegistryHandler) + mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["email"] == ownerErroring { + // Hold until the denying owner's check has also completed, so both + // results are genuine rather than one being a cancellation artifact. + release() + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-" + body["email"]}) + }) + mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, _ *http.Request) { + release() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "consents": consentsGrantedTo(testConsumerSD, []string{"revoked"}), + }) + }) + server := httptest.NewServer(mux) + defer server.Close() + + // Index 0 errors, index 1 denies. + resolver := newOwnerResolver(t, ownedBy(ownerErroring, ownerDenying)) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.FailOpen = boolPtr(true) + + const id = uint32(270) + storeRequest(id) + resp := newMockResponse(id, []byte(`{"id":"x"}`)) + + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, + "an owner's explicit deny must not be masked by another owner's dependency error") + assert.Equal(t, DefaultDenyResponseBody, string(resp.writtenBody)) +} + +// TestCheckOwners_ErrorStillAppliesFailPolicy verifies the reordering did not +// swallow the error path: with no deny anywhere, a dependency error is still +// what decides, and fail_open still governs it. +func TestCheckOwners_ErrorStillAppliesFailPolicy(t *testing.T) { + server := newFailingConsentManager() + defer server.Close() + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() + + tests := []struct { + name string + failOpen bool + wantDenied bool + }{ + {name: "fail-closed denies", failOpen: false, wantDenied: true}, + {name: "fail-open passes through", failOpen: true, wantDenied: false}, + } + + // Each case needs its own request id so the context-store entries cannot + // collide between subtests. + nextRequestID := uint32(280) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearContextStore() + consent.ResetCaches() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.FailOpen = boolPtr(tt.failOpen) + + nextRequestID++ + id := nextRequestID + storeRequest(id) + resp := newMockResponse(id, []byte(`{"id":"x"}`)) + + (&ConsentFilter{}).ResponseFilter(cfg, resp) + + if tt.wantDenied { + assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus) + return + } + assert.Equal(t, 0, resp.writtenStatus, "a dependency error with fail_open must still pass through") + }) + } +} From 29c58c8061ec31d177a7bf6bdfc0cb77222f7907 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:43:52 +0200 Subject: [PATCH 33/41] perf(plugin): amortise the request-context overflow eviction (N-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the store was at `MaxRequestContexts`, `evictForSpaceLocked` swept expired entries (a full map scan) and, if none had expired, scanned the whole map again to find the single oldest — all under `requestContextMu`, all to free exactly one slot. The next request then repeated both scans. At the 100k cap that is two ~100k-entry scans per incoming request, serialised behind one mutex. And the store only *reaches* the cap under the leak or the abort-flood the cap exists to contain, so the O(n) path was guaranteed to engage precisely when load was already pathological: the cap converted an unbounded memory leak into an unbounded latency cliff, which undercuts the mitigation it was added to provide. An overflow now evicts a batch of the oldest entries (`contextEvictionBatch`, 1% of the cap) in one pass, so the scan is paid once per thousand requests rather than on every one — the following requests find room without scanning at all. The eviction still takes the *oldest* entries, so which contexts are dropped is unchanged; only how many, and how often the scan runs. The cap test now asserts the batch semantics, and a second test pins the amortisation directly: after an overflow, the next `contextEvictionBatch-1` stores must not evict again. Co-Authored-By: Claude Opus 5 --- internal/plugin/context.go | 59 ++++++++++++++++++++++++++------- internal/plugin/context_test.go | 50 ++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/internal/plugin/context.go b/internal/plugin/context.go index 482a79c..251a7ac 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -21,6 +21,7 @@ import ( "consent-plugin/internal/logging" "consent-plugin/internal/metrics" "fmt" + "sort" "sync" "time" ) @@ -63,6 +64,18 @@ const ( // backstop against an unauthenticated memory-exhaustion primitive: a client // that opens requests and aborts before the response leaks one entry each. MaxRequestContexts = 100_000 + + // contextEvictionBatch is how many entries an overflow evicts at once. + // + // Freeing a single slot per overflow meant a full store paid a whole-map scan + // on EVERY subsequent request, serialised behind the store's mutex — and the + // store only reaches the cap under the leak or abort-flood the cap exists to + // contain. The O(n) path was therefore guaranteed to engage exactly when load + // was already pathological, converting an unbounded memory leak into an + // unbounded latency cliff. Evicting a batch amortises the scan over the whole + // batch, so the cost is paid once per contextEvictionBatch requests instead + // of once per request. + contextEvictionBatch = MaxRequestContexts / 100 ) // storedRequestContext is one entry plus the time it was stored, which is what @@ -128,8 +141,8 @@ func startContextJanitor() { // It overwrites any previously stored context for the same key. // // The store is bounded: when it is full, expired entries are swept first and, -// failing that, the oldest entry is evicted so a new request is never refused -// service by a leak from an older one. +// failing that, a batch of the oldest entries is evicted, so a new request is +// never refused service by a leak from an older one. func StoreRequestContext(requestKey string, ctx *RequestContext) { startContextJanitor() @@ -145,25 +158,47 @@ func StoreRequestContext(requestKey string, ctx *RequestContext) { requestContextStore[requestKey] = storedRequestContext{ctx: ctx, storedAt: now} } -// evictForSpaceLocked makes room in a full store: expired entries first, then — -// if everything is still live — the single oldest entry. Callers must hold +// evictForSpaceLocked makes room in a full store: expired entries first and, if +// everything is still live, a batch of the oldest. Callers must hold // requestContextMu. +// +// Both paths scan the map, which is why they free many slots rather than one: +// the next contextEvictionBatch requests then find room without scanning at all. func evictForSpaceLocked(now time.Time) { if n := sweepLocked(now); n > 0 { logging.WarnfEvery("context-store-full", "request-context store full (%d), evicted %d expired entr(ies)", MaxRequestContexts, n) return } - oldestKey, oldestAt := "", time.Time{} + if n := evictOldestLocked(contextEvictionBatch); n > 0 { + logging.ErrorfEvery("context-store-overflow", + "request-context store full (%d) with no expired entries, evicted the %d oldest", MaxRequestContexts, n) + } +} + +// evictOldestLocked removes up to batch of the oldest entries and returns how +// many it removed. Callers must hold requestContextMu. +func evictOldestLocked(batch int) int { + if batch <= 0 || len(requestContextStore) == 0 { + return 0 + } + type aged struct { + key string + storedAt time.Time + } + entries := make([]aged, 0, len(requestContextStore)) for key, entry := range requestContextStore { - if oldestAt.IsZero() || entry.storedAt.Before(oldestAt) { - oldestKey, oldestAt = key, entry.storedAt - } + entries = append(entries, aged{key: key, storedAt: entry.storedAt}) } - if oldestKey != "" { - delete(requestContextStore, oldestKey) - contextsEvicted++ - logging.ErrorfEvery("context-store-overflow", "request-context store full (%d) with no expired entries, evicted the oldest", MaxRequestContexts) + sort.Slice(entries, func(i, j int) bool { return entries[i].storedAt.Before(entries[j].storedAt) }) + + if batch > len(entries) { + batch = len(entries) + } + for _, entry := range entries[:batch] { + delete(requestContextStore, entry.key) } + contextsEvicted += uint64(batch) + return batch } // sweepRequestContexts removes every entry stored more than RequestContextTTL diff --git a/internal/plugin/context_test.go b/internal/plugin/context_test.go index 9720c73..d8ceeb8 100644 --- a/internal/plugin/context_test.go +++ b/internal/plugin/context_test.go @@ -149,6 +149,11 @@ func TestSweepRequestContexts(t *testing.T) { // TestStoreRequestContext_EnforcesCap verifies a full store makes room instead // of growing without bound — the backstop against a client that opens requests // and aborts before the response phase. +// +// It also pins the amortisation: an overflow evicts a BATCH, so the whole-map +// scan is paid once per contextEvictionBatch requests rather than on every +// request. Freeing one slot at a time turned the cap from a memory bound into a +// latency cliff, engaging precisely under the flood the cap exists to contain. func TestStoreRequestContext_EnforcesCap(t *testing.T) { clearContextStore() defer clearContextStore() @@ -166,14 +171,53 @@ func TestStoreRequestContext_EnforcesCap(t *testing.T) { StoreRequestContext("newest", &RequestContext{Path: "/new"}) - assert.Equal(t, MaxRequestContexts, RequestContextStoreSize(), "the store must not exceed its cap") - assert.Equal(t, uint64(1), RequestContextsEvicted()) + assert.Equal(t, MaxRequestContexts-contextEvictionBatch+1, RequestContextStoreSize(), + "an overflow must evict a batch, not a single entry") + assert.Equal(t, uint64(contextEvictionBatch), RequestContextsEvicted()) + + // The batch taken is the oldest one. _, ok := LoadAndDeleteRequestContext("live-0") - assert.False(t, ok, "the oldest entry is the one evicted") + assert.False(t, ok, "the oldest entries are the ones evicted") + _, ok = LoadAndDeleteRequestContext(fmt.Sprintf("live-%d", contextEvictionBatch-1)) + assert.False(t, ok, "the whole oldest batch is evicted") + _, ok = LoadAndDeleteRequestContext(fmt.Sprintf("live-%d", contextEvictionBatch)) + assert.True(t, ok, "entries beyond the batch survive") _, ok = LoadAndDeleteRequestContext("newest") assert.True(t, ok, "the new request must still be served") } +// TestStoreRequestContext_EvictionIsAmortised verifies the requests following an +// overflow are served from the headroom the batch freed, without evicting (and +// therefore without scanning) again. +func TestStoreRequestContext_EvictionIsAmortised(t *testing.T) { + clearContextStore() + defer clearContextStore() + + now := time.Now() + requestContextMu.Lock() + for i := 0; i < MaxRequestContexts; i++ { + requestContextStore[fmt.Sprintf("live-%d", i)] = storedRequestContext{ + ctx: &RequestContext{Path: "/x"}, + storedAt: now.Add(time.Duration(i) * time.Millisecond), + } + } + requestContextMu.Unlock() + + StoreRequestContext("overflow", &RequestContext{Path: "/new"}) + evictedAfterFirst := RequestContextsEvicted() + require.Equal(t, uint64(contextEvictionBatch), evictedAfterFirst) + + // The batch freed contextEvictionBatch slots and one was consumed by the + // store above, so this many more fit without any further eviction. + for i := 0; i < contextEvictionBatch-1; i++ { + StoreRequestContext(fmt.Sprintf("after-%d", i), &RequestContext{Path: "/y"}) + } + + assert.Equal(t, evictedAfterFirst, RequestContextsEvicted(), + "requests within the freed headroom must not trigger another scan") + assert.Equal(t, MaxRequestContexts, RequestContextStoreSize()) +} + // TestRequestContext_HoldsNoHeaders pins the property that made a leaked entry a // credential leak: the context must not retain the request's Authorization // header (or any other). From acdb6b6fa16778f6af21fe798e87e197b24698ee Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:47:13 +0200 Subject: [PATCH 34/41] test(plugin): make the N-2 ranking test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression test I added with N-2 drove the ordering through two concurrent HTTP checks held open by a barrier. It was inherently racy and failed about half the time: whichever owner completes first cancels the other, so the sibling's deny can arrive as a `context.Canceled` error rather than a deny, and the test then passed or failed on timing instead of on the property it was meant to pin. A flaky test that guards a security property is worse than none — it teaches people to re-run. The result reduction is now a pure function, `reduceOwnerResults`, with `ownerCheckResult` lifted to package scope, so the ranking can be exercised with scripted results at the level where it actually lives. The table covers a deny at a higher index than an error (the finding, which still fails against the index-only reduction), a deny at a lower index, two denies, error-only under both fail policies, a cancellation caused by a sibling deny versus one caused by the phase deadline, and an owner whose check never started. No behaviour change: `checkOwners` calls the extracted function and the ranking rule is unchanged. Co-Authored-By: Claude Opus 5 --- internal/plugin/consent.go | 68 +++++++----- internal/plugin/consent_test.go | 184 ++++++++++++++++++++------------ 2 files changed, 156 insertions(+), 96 deletions(-) diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index 0f8ea3d..b5428f8 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -426,20 +426,11 @@ const maxConcurrentConsentChecks = 8 // // Checks run concurrently up to maxConcurrentConsentChecks and short-circuit on // the first problem — the remaining calls are cancelled, since nothing they -// could return would change the answer. The reported outcome is always the -// lowest-indexed problem, so the decision (and the audit record) does not depend -// on which goroutine happened to finish first. +// could return would change the answer. The results are then reduced by +// reduceOwnerResults, which ranks them so the verdict does not depend on which +// goroutine happened to finish first. func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestContext, client *consent.Client, claims []ownerClaim, consumerSD string) responseOutcome { - type checkResult struct { - outcome responseOutcome - err error - request consent.ConsentRequest - record checkedOwner - problem bool - attempted bool - } - - results := make([]checkResult, len(claims)) + results := make([]ownerCheckResult, len(claims)) checksCtx, cancelChecks := context.WithCancel(ctx) defer cancelChecks() @@ -503,6 +494,44 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } wg.Wait() + return reduceOwnerResults(cfg, key, reqCtx, ctx.Err(), results) +} + +// ownerCheckResult is one owner's consent check within a response. +type ownerCheckResult struct { + // outcome is the deny to enforce, set only when the owner denied. + outcome responseOutcome + // err is the dependency failure, set only when the check could not complete. + err error + // request is the check that was made, for the audit record on an error. + request consent.ConsentRequest + // record is this owner's audit entry (allow or deny). + record checkedOwner + // problem is true for a deny or an error, i.e. anything but a plain allow. + problem bool + // attempted is false for an owner whose check never started because the + // phase was already cancelled. + attempted bool +} + +// reduceOwnerResults collapses the per-owner results into the response outcome, +// enforcing deny_all. +// +// Results are ranked by DECISIVENESS first and index second. A deny is a +// definite answer; an error is the absence of one, and only the absence is +// subject to the operator's fail policy. Reducing by index alone ranked the two +// purely by position, so an error at a lower index could mask a deny at a higher +// one — and under `fail_open: true` that released data an owner had explicitly +// refused, with the audit record showing the contradiction (a per-owner deny +// alongside an enforced allow). Scanning for a deny across all results first +// removes the ordering dependency; within each pass the lowest index still wins, +// so the verdict stays deterministic rather than depending on which goroutine +// finished first. +// +// phaseErr is the response phase's own context error, which distinguishes a call +// cancelled because a sibling already denied (not a failure in itself) from one +// cancelled because the whole phase ran out of budget (which is). +func reduceOwnerResults(cfg *Config, key string, reqCtx *RequestContext, phaseErr error, results []ownerCheckResult) responseOutcome { // Every owner that was actually consulted is recorded, whatever the verdict, // so the audit log names them all rather than only the first refusal. checked := make([]checkedOwner, 0, len(results)) @@ -512,17 +541,6 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } } - // Results are reduced by DECISIVENESS first and index second. - // - // A deny is a definite answer; an error is the absence of one, and only the - // absence is subject to the operator's fail policy. Reducing by index alone - // ranked the two purely by position, so an error at a lower index could mask - // a deny at a higher one — and under `fail_open: true` that released data an - // owner had explicitly refused, with the audit record showing the - // contradiction (a per-owner deny alongside an enforced allow). Scanning for - // a deny across all results first removes the ordering dependency; within - // each pass the lowest index still wins, so the verdict stays deterministic - // rather than depending on which goroutine finished first. for _, result := range results { if result.attempted && result.problem && result.err == nil { outcome := result.outcome @@ -537,7 +555,7 @@ func checkOwners(ctx context.Context, cfg *Config, key string, reqCtx *RequestCo } // A call cancelled because a *different* owner already denied is not // itself a failure; that deny was returned by the pass above. - if errors.Is(result.err, context.Canceled) && ctx.Err() == nil { + if errors.Is(result.err, context.Canceled) && phaseErr == nil { continue } logging.ErrorfEvery("consent-check", "ResponseFilter: consent check error for request %s: %s", key, logging.Sanitize(result.err.Error())) diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 1102283..14ef1c2 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -23,6 +23,7 @@ import ( "consent-plugin/internal/ownerresolver" "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -1296,86 +1297,127 @@ func TestRequestFilter_CorrelationID(t *testing.T) { }) } -// twoPartyBarrier releases both callers only once both have arrived, so a test -// can force two concurrent consent checks to complete before either cancels the -// other. It fails the test rather than hanging if the second never arrives. -func twoPartyBarrier(t *testing.T) func() { - t.Helper() - arrived := make(chan struct{}, 2) - released := make(chan struct{}) - var once sync.Once - return func() { - arrived <- struct{}{} - if len(arrived) == 2 { - once.Do(func() { close(released) }) +// TestReduceOwnerResults is the regression test for the ordering hole the +// concurrency work opened, exercised at the level where the ranking actually +// lives. +// +// Driving it through two concurrent HTTP checks turned out to be inherently +// racy: whichever owner finishes first cancels the other, so a sibling's deny +// can arrive as a cancellation instead — the test passed or failed on timing +// rather than on the property. Reducing scripted results is deterministic and +// pins the rule directly. +func TestReduceOwnerResults(t *testing.T) { + reqCtx := &RequestContext{Method: "GET", Path: "/data"} + + denied := func(owner string) ownerCheckResult { + return ownerCheckResult{ + attempted: true, + problem: true, + outcome: responseOutcome{ + decision: decisionDeny, reason: "no granted consent", requestID: "req-1", + subject: owner, resource: "/data", method: "GET", + }, + record: checkedOwner{subject: owner, resource: "/data", decision: decisionDeny}, } - select { - case <-released: - case <-time.After(5 * time.Second): - t.Errorf("barrier timed out: the second concurrent check never arrived") + } + allowed := func(owner string) ownerCheckResult { + return ownerCheckResult{ + attempted: true, + record: checkedOwner{subject: owner, resource: "/data", decision: decisionAllow}, } } -} - -// TestCheckOwners_DenyOutranksDependencyError is the regression test for the -// ordering hole the concurrency work opened. -// -// Two owners are checked concurrently: the one at index 0 errors (HTTP 500) and -// the one at index 1 denies. Reducing the results by index alone returned the -// error, which under fail_open:true releases the response — even though an owner -// has explicitly refused. A deny is a definite answer and must outrank the -// absence of one, whatever position it landed in. -func TestCheckOwners_DenyOutranksDependencyError(t *testing.T) { - clearContextStore() - consent.ResetCaches() - - const ( - ownerErroring = "did:key:zErroring" - ownerDenying = "did:key:zDenying" - ) - release := twoPartyBarrier(t) - - mux := http.NewServeMux() - mux.HandleFunc("/v1/participants", participantRegistryHandler) - mux.HandleFunc("/v1/users/identifier/search", func(w http.ResponseWriter, r *http.Request) { - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - if body["email"] == ownerErroring { - // Hold until the denying owner's check has also completed, so both - // results are genuine rather than one being a cancellation artifact. - release() - w.WriteHeader(http.StatusInternalServerError) - return + errored := func(owner string, err error) ownerCheckResult { + return ownerCheckResult{ + attempted: true, + problem: true, + err: err, + request: consent.ConsentRequest{Subject: owner, Resource: "/data", Method: "GET"}, } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"userIdentifier": "uid-" + body["email"]}) - }) - mux.HandleFunc("/v1/consents/participants/", func(w http.ResponseWriter, _ *http.Request) { - release() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "consents": consentsGrantedTo(testConsumerSD, []string{"revoked"}), - }) - }) - server := httptest.NewServer(mux) - defer server.Close() + } - // Index 0 errors, index 1 denies. - resolver := newOwnerResolver(t, ownedBy(ownerErroring, ownerDenying)) - defer resolver.Close() + dependencyErr := errors.New("consent client: consents lookup returned status 500") - cfg := newTestConfig(server.URL, resolver.URL+"/resolve") - cfg.FailOpen = boolPtr(true) + tests := []struct { + name string + failOpen bool + phaseErr error + results []ownerCheckResult + wantDecision string + wantSubject string + wantChecked int + }{ + { + name: "every owner allows", failOpen: false, + results: []ownerCheckResult{allowed("a"), allowed("b")}, + wantDecision: decisionAllow, wantChecked: 2, + }, + { + name: "one deny denies them all", failOpen: false, + results: []ownerCheckResult{allowed("a"), denied("b")}, + wantDecision: decisionDeny, wantSubject: "b", wantChecked: 2, + }, + { + // The finding: with fail_open the error path allows, so ranking the + // error first releases data owner b has explicitly refused. + name: "a deny at a higher index outranks an error at a lower one", failOpen: true, + results: []ownerCheckResult{errored("a", dependencyErr), denied("b")}, + wantDecision: decisionDeny, wantSubject: "b", wantChecked: 1, + }, + { + name: "a deny at a lower index still wins", failOpen: true, + results: []ownerCheckResult{denied("a"), errored("b", dependencyErr)}, + wantDecision: decisionDeny, wantSubject: "a", wantChecked: 1, + }, + { + name: "the lowest-indexed deny is reported", failOpen: false, + results: []ownerCheckResult{denied("a"), denied("b")}, + wantDecision: decisionDeny, wantSubject: "a", wantChecked: 2, + }, + { + name: "with no deny anywhere, fail-open allows on an error", failOpen: true, + results: []ownerCheckResult{allowed("a"), errored("b", dependencyErr)}, + wantDecision: decisionAllow, wantChecked: 1, + }, + { + name: "with no deny anywhere, fail-closed denies on an error", failOpen: false, + results: []ownerCheckResult{allowed("a"), errored("b", dependencyErr)}, + wantDecision: decisionDeny, wantChecked: 1, + }, + { + // A sibling deny cancels the rest; those cancellations are not + // failures, and the deny that caused them is what gets reported. + name: "a cancellation caused by a sibling deny is ignored", failOpen: true, + results: []ownerCheckResult{errored("a", context.Canceled), denied("b")}, + wantDecision: decisionDeny, wantSubject: "b", wantChecked: 1, + }, + { + // But a cancellation from the phase deadline IS a failure. + name: "a cancellation from the phase deadline applies the fail policy", failOpen: false, + phaseErr: context.DeadlineExceeded, + results: []ownerCheckResult{errored("a", context.Canceled), allowed("b")}, + wantDecision: decisionDeny, wantChecked: 1, + }, + { + name: "owners whose check never started are ignored", failOpen: false, + results: []ownerCheckResult{allowed("a"), {}}, + wantDecision: decisionAllow, wantChecked: 1, + }, + } - const id = uint32(270) - storeRequest(id) - resp := newMockResponse(id, []byte(`{"id":"x"}`)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{FailOpen: boolPtr(tt.failOpen)} - (&ConsentFilter{}).ResponseFilter(cfg, resp) + got := reduceOwnerResults(cfg, "req-1", reqCtx, tt.phaseErr, tt.results) - assert.Equal(t, DefaultDenyStatusCode, resp.writtenStatus, - "an owner's explicit deny must not be masked by another owner's dependency error") - assert.Equal(t, DefaultDenyResponseBody, string(resp.writtenBody)) + assert.Equal(t, tt.wantDecision, got.decision) + if tt.wantSubject != "" { + assert.Equal(t, tt.wantSubject, got.subject, "the reported owner must be the deciding one") + } + assert.Len(t, got.checked, tt.wantChecked, + "every consulted owner must reach the audit log, and only those") + }) + } } // TestCheckOwners_ErrorStillAppliesFailPolicy verifies the reordering did not From 8d196d3e7818002de63a387e28f87b76f9d348f4 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:48:27 +0200 Subject: [PATCH 35/41] feat(plugin): surface and optionally require purpose scoping (N-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coversPurpose` returns true for an empty purpose, which is a defensible and documented rule — but it made half of the C-2 fix contingent on a *different service* populating an optional field. A resolver deployment whose rules never set `purpose` runs with purpose scoping entirely disabled, and nothing anywhere said so: no log line, no metric, no way to demand it. The failure is quiet and compliance-shaped: the resolver is upgraded, a rule loses its purpose mapping, and consent granted for "insurance quote" now also authorises release for research. The consumer match still holds, so it narrows rather than opens the gate — but a property that was supposed to be enforced silently stopped being, and no signal would have revealed it. - `consent_purpose_unconstrained_total` counts every check run without a purpose to match against, so the state is alertable rather than merely documented, and a rate-limited warning says the same thing in the log. - `require_purpose` (default false) turns a claim without a purpose into a denial. It defaults off because requiring it would break every deployment whose resolver does not emit one yet; turn it on once yours does and the property is enforced rather than hoped for. Being a policy the operator asked for rather than an outage, it is `failAlwaysClosed` — `fail_open` does not lift it. Tests: `checkPurposeScoping` as a table (counted when not required, denied when required, denied even with fail_open), plus the `coversPurpose` cases the re-review flagged as missing at the consent-client level — matching purpose allows, a different purpose denies, a consent covering no purpose denies a purpose-scoped check. Co-Authored-By: Claude Opus 5 --- README.md | 4 +- internal/consent/client_test.go | 69 +++++++++++++++++++++++++++ internal/metrics/metrics.go | 37 ++++++++++++--- internal/plugin/config.go | 13 ++++++ internal/plugin/consent.go | 27 +++++++++++ internal/plugin/consent_test.go | 83 +++++++++++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cd618d8..9268afb 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,8 @@ Access is **allowed** only if a returned consent satisfies all of: - `status == "granted"`; - it was granted to the **consuming participant** identified from the token (a consent names one consumer; one granted to X is not authority for Y); -- it covers the **purpose**, when the resolver named one for the claim; +- it covers the **purpose**, when the resolver named one for the claim (set + `require_purpose` to deny rather than proceed unscoped when it does not); - it covers the **data resource**, when the resolver scoped the claim to one. The owner DID is sent as the user `email` (the consent-manager's @@ -114,6 +115,7 @@ Configured via the APISIX route plugin JSON (identically on both `ext-plugin-pre | `owner_resolver_timeout` | `int` | No | `2000` | Per-call timeout in ms for `/resolve`. Range 1–60000. | | `service` | `string` | No | — | Logical dataset id sent to the OwnerResolver as `resource.service`, so it can select the rule for this route. | | `response_phase_timeout` | `int` | No | `10000` | Budget in ms for the **entire** response phase — party lookups, `/resolve`, and every per-owner consent check together. APISIX holds the buffered response for this whole time, so it is bounded independently of the per-call timeouts. Range 1–120000. | +| `require_purpose` | `bool` | No | `false` | Deny when a resolved claim names no processing purpose. Purpose matching depends on the OwnerResolver populating an optional field, so a resolver that never sets it runs with purpose scoping silently disabled; turn this on once yours emits one. `consent_purpose_unconstrained_total` counts the checks it would deny. | | `max_resolve_body_bytes` | `int` | No | `1048576` | Maximum upstream body forwarded to the OwnerResolver. A larger body is denied rather than copied — the body is held whole, validated and marshalled again, so the peak footprint is ~3× its size per in-flight request on top of APISIX's own buffering. Range 1–104857600. | | `max_owners_per_response` | `int` | No | `50` | Maximum distinct data owners checked for one response. A response resolving to more is denied rather than answered after an unbounded number of consent calls. Range 1–1000. | | `consumer_claim` | `string` | No | `verifiableCredential.issuer` | Dotted claim path naming the **consuming participant**. Supports array indexing (`verifiableCredential[0].issuer`), and a bare segment landing on an array traverses its first element — a Verifiable Presentation routinely carries `verifiableCredential` as an array. Used for the contract lookup and to scope the consent match — never for ownership. | diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index b5f9bd6..5a4cd4a 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -784,3 +784,72 @@ func TestResolveUserIdentifier_UnknownSubjectNotCached(t *testing.T) { defer m.mu.Unlock() assert.Equal(t, 3, m.searchCalls, "an unknown subject must not be cached as unknown") } + +// TestCheckConsent_PurposeScoped verifies the purpose leg of the consumer / +// purpose / resource match. It is load-bearing once the resolver names a +// purpose: a consent granted for "insurance quote" must not authorise a release +// for research. +func TestCheckConsent_PurposeScoped(t *testing.T) { + const ( + grantedPurpose = "insurance-quote" + otherPurpose = "research" + ) + + tests := []struct { + name string + consentPurposes []string + requestPurpose string + wantDecision Decision + }{ + { + name: "matching purpose allows", + consentPurposes: []string{grantedPurpose}, + requestPurpose: grantedPurpose, + wantDecision: DecisionAllow, + }, + { + name: "a different purpose denies", + consentPurposes: []string{grantedPurpose}, + requestPurpose: otherPurpose, + wantDecision: DecisionDeny, + }, + { + name: "one of several purposes matching allows", + consentPurposes: []string{otherPurpose, grantedPurpose}, + requestPurpose: grantedPurpose, + wantDecision: DecisionAllow, + }, + { + name: "a consent covering no purpose denies a purpose-scoped check", + consentPurposes: nil, + requestPurpose: grantedPurpose, + wantDecision: DecisionDeny, + }, + { + // The caller could not determine a purpose, so it is not part of the + // match; the consumer match still applies. + name: "no requested purpose leaves the check unscoped", + consentPurposes: []string{grantedPurpose}, + requestPurpose: "", + wantDecision: DecisionAllow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetCredCache() + m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, consentPurposes: tt.consentPurposes} + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static", ProviderSD: "sd"}) + + resp, err := c.CheckConsent(context.Background(), ConsentRequest{ + Subject: "did:key:zOwner", Consumer: testConsumerSD, Purpose: tt.requestPurpose, + }) + require.NoError(t, err) + assert.Equal(t, tt.wantDecision, resp.Decision) + if tt.wantDecision == DecisionDeny { + assert.Contains(t, resp.Reason, "no granted consent") + } + }) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 35dacb3..df7e5d6 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -42,12 +42,13 @@ import ( // Metric names. The consent_ prefix keeps them together in a shared registry. const ( - decisionsMetric = "consent_decisions_total" - dependencyCallsMetric = "consent_dependency_calls_total" - dependencyLatency = "consent_dependency_duration_seconds" - contextStoreSizeMetric = "consent_request_context_store_size" - contextEvictedMetric = "consent_request_contexts_evicted_total" - auditDroppedMetric = "consent_audit_events_dropped_total" + decisionsMetric = "consent_decisions_total" + dependencyCallsMetric = "consent_dependency_calls_total" + dependencyLatency = "consent_dependency_duration_seconds" + purposeUnconstrainedMetric = "consent_purpose_unconstrained_total" + contextStoreSizeMetric = "consent_request_context_store_size" + contextEvictedMetric = "consent_request_contexts_evicted_total" + auditDroppedMetric = "consent_audit_events_dropped_total" ) // Dependency names used as the "dependency" label. @@ -82,6 +83,11 @@ var ( // latency holds one histogram per dependency. latency = map[string]*histogram{} + // purposeUnconstrained counts consent checks run without a processing + // purpose to match against, i.e. checks where half of the consumer/purpose + // scoping was not actually applied. + purposeUnconstrained uint64 + // gauges are read at scrape time from whoever owns the number, so this // package never has to be told when a store's size changes. gaugeMu sync.Mutex @@ -135,6 +141,20 @@ func RecordDependencyCall(dependency, outcome string, duration time.Duration) { h.observe(duration.Seconds()) } +// RecordPurposeUnconstrained counts one consent check made without a processing +// purpose to scope it. +// +// Purpose matching depends on the OwnerResolver populating an optional field. A +// resolver whose rules never set it leaves purpose scoping entirely disabled, +// which is a silent narrowing of a compliance property — a consent granted for +// one purpose then authorises release for any other. This makes that state +// visible and alertable instead of merely documented. +func RecordPurposeUnconstrained() { + mu.Lock() + defer mu.Unlock() + purposeUnconstrained++ +} + // RegisterGauge publishes a value read at scrape time. The owner of the number // keeps owning it; this package only asks for it. func RegisterGauge(name string, read func() float64) { @@ -164,6 +184,10 @@ func render() string { writeCounter(&out, dependencyCallsMetric, "Outbound calls to a dependency, by outcome.", "dependency", "outcome", dependencyCalls) + fmt.Fprintf(&out, "# HELP %s Consent checks run without a processing purpose to scope them.\n", purposeUnconstrainedMetric) + fmt.Fprintf(&out, "# TYPE %s counter\n", purposeUnconstrainedMetric) + fmt.Fprintf(&out, "%s %d\n", purposeUnconstrainedMetric, purposeUnconstrained) + fmt.Fprintf(&out, "# HELP %s Duration of outbound dependency calls in seconds.\n", dependencyLatency) fmt.Fprintf(&out, "# TYPE %s histogram\n", dependencyLatency) for _, dependency := range sortedMapKeys(latency) { @@ -246,6 +270,7 @@ func Reset() { decisions = map[labelPair]uint64{} dependencyCalls = map[labelPair]uint64{} latency = map[string]*histogram{} + purposeUnconstrained = 0 mu.Unlock() gaugeMu.Lock() diff --git a/internal/plugin/config.go b/internal/plugin/config.go index e736fdc..2c84fa3 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -210,6 +210,19 @@ type Config struct { // Defaults to DefaultResponsePhaseTimeout. ResponsePhaseTimeout int `json:"response_phase_timeout,omitempty"` + // RequirePurpose makes a resolved claim that names no processing purpose a + // denial instead of an unscoped check. + // + // Purpose matching depends on the OwnerResolver populating an optional field, + // so a resolver whose rules never set it silently runs with purpose scoping + // disabled — a consent granted for one purpose then authorises release for + // any other. Defaults to false, because requiring it would break every + // deployment whose resolver does not emit it yet; turn it on once yours does, + // and the property becomes enforced rather than hoped for. The + // consent_purpose_unconstrained_total metric counts the checks this would + // have denied. + RequirePurpose bool `json:"require_purpose,omitempty"` + // MaxResolveBodyBytes caps the upstream body forwarded to the OwnerResolver. // // The body is read whole, validated as JSON, and marshalled again into the diff --git a/internal/plugin/consent.go b/internal/plugin/consent.go index b5428f8..836c3cf 100644 --- a/internal/plugin/consent.go +++ b/internal/plugin/consent.go @@ -367,6 +367,10 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke if err != nil { return failOutcome(cfg, failAlwaysClosed, err.Error(), key, nil) } + if outcome, ok := checkPurposeScoping(cfg, key, claims); !ok { + return outcome + } + if len(claims) > cfg.MaxOwnersPerResponse { logging.WarnfEvery("owner-cap", "ResponseFilter: %d distinct data owners for request %s exceeds max_owners_per_response=%d; denying", len(claims), key, cfg.MaxOwnersPerResponse) @@ -378,6 +382,29 @@ func (c *ConsentFilter) evaluateWithResolver(cfg *Config, w pkgHTTP.Response, ke return checkOwners(phaseCtx, cfg, key, reqCtx, consentClient, claims, consumerSD) } +// checkPurposeScoping reports whether every resolved claim carries a processing +// purpose, and what to do when one does not. +// +// With require_purpose set, a claim without a purpose denies: it is a policy the +// operator asked for, not an outage, so the fail policy does not apply to it. +// Otherwise the check proceeds unscoped by purpose and is counted, so a resolver +// that has silently stopped emitting purposes is visible in the metrics instead +// of quietly widening what a consent authorises. +func checkPurposeScoping(cfg *Config, key string, claims []ownerClaim) (responseOutcome, bool) { + for _, claim := range claims { + if claim.purpose != "" { + continue + } + if cfg.RequirePurpose { + logging.WarnfEvery("purpose-required", "ResponseFilter: resolved claim without a processing purpose for request %s and require_purpose is set; denying", key) + return failOutcome(cfg, failAlwaysClosed, "resolved claim without a processing purpose", key, nil), false + } + metrics.RecordPurposeUnconstrained() + logging.WarnfEvery("purpose-unconstrained", "ResponseFilter: the resolver named no processing purpose, so consent is matched on the consumer alone; set require_purpose once the resolver emits one") + } + return responseOutcome{}, true +} + // ownerClaim is one distinct (owner, dataResource) pair to check. type ownerClaim struct { owner string diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index 14ef1c2..c0c7c92 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -20,6 +20,8 @@ package plugin import ( "consent-plugin/internal/audit" "consent-plugin/internal/consent" + "consent-plugin/internal/logging" + "consent-plugin/internal/metrics" "consent-plugin/internal/ownerresolver" "context" "encoding/json" @@ -1464,3 +1466,84 @@ func TestCheckOwners_ErrorStillAppliesFailPolicy(t *testing.T) { }) } } + +// TestCheckPurposeScoping covers what happens when the resolver names no +// processing purpose for a claim. +// +// Purpose matching depends on a different service populating an optional field, +// so a resolver whose rules stop emitting it silently disables half of the +// consumer/purpose scoping — a consent granted for one purpose then authorises +// release for any other. Without require_purpose that is counted and logged; +// with it, it denies. +func TestCheckPurposeScoping(t *testing.T) { + tests := []struct { + name string + requirePurpose bool + failOpen bool + claims []ownerClaim + wantOK bool + wantUnconstained int + }{ + { + name: "every claim carries a purpose", + claims: []ownerClaim{{owner: "a", purpose: "insurance-quote"}, {owner: "b", purpose: "insurance-quote"}}, + wantOK: true, + }, + { + name: "a missing purpose is counted when not required", + claims: []ownerClaim{{owner: "a"}, {owner: "b", purpose: "research"}}, + wantOK: true, + wantUnconstained: 1, + }, + { + name: "every unscoped claim is counted", + claims: []ownerClaim{{owner: "a"}, {owner: "b"}}, + wantOK: true, + wantUnconstained: 2, + }, + { + name: "a missing purpose denies when required", + requirePurpose: true, + claims: []ownerClaim{{owner: "a", purpose: "research"}, {owner: "b"}}, + wantOK: false, + }, + { + // require_purpose is a policy the operator asked for, not an outage, + // so fail_open must not lift it. + name: "require_purpose denies even with fail-open", + requirePurpose: true, + failOpen: true, + claims: []ownerClaim{{owner: "a"}}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics.Reset() + t.Cleanup(metrics.Reset) + logging.ResetSuppression() + + cfg := newTestConfig("http://consent.invalid", "http://resolver.invalid/resolve") + cfg.RequirePurpose = tt.requirePurpose + cfg.FailOpen = boolPtr(tt.failOpen) + + outcome, ok := checkPurposeScoping(cfg, "req-1", tt.claims) + + assert.Equal(t, tt.wantOK, ok) + if !tt.wantOK { + assert.Equal(t, decisionDeny, outcome.decision, + "require_purpose is a policy, not an outage — fail_open must not lift it") + return + } + assert.Contains(t, metricsExposition(), fmt.Sprintf("consent_purpose_unconstrained_total %d", tt.wantUnconstained)) + }) + } +} + +// metricsExposition renders the current metrics for assertion. +func metricsExposition() string { + recorder := httptest.NewRecorder() + metrics.Handler().ServeHTTP(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)) + return recorder.Body.String() +} From 192aa72f402ceea10332bcdc990310129910c497 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:50:08 +0200 Subject: [PATCH 36/41] fix(consent): keep dependency response bodies out of errors that escape (N-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sanitize`'s doc comment stated the problem exactly — "messages built by wrapping dependency errors embed the dependency's response body, which can carry identifiers or other personal data" — and then collapsed control characters and truncated to 200 characters. Neither removes the body. Truncation is not redaction, and the surviving prefix of a JSON error page is usually precisely the part with the identifiers in it. Five call sites in the consent client (and one in the resolver client) embedded `truncateBody(body)` in their errors, and those errors become the plugin's decision reason — exported to the audit sink and written to stdout. A consent-manager 500 echoing the user identifier landed in both, now single-line and ≤200 chars, which is tidier but no less identifying. `unexpectedStatus` now builds a stable, low-cardinality classification ("consent client: identifier search returned status 500") and sends the body to a rate-limited DEBUG log instead, so it is available when debugging, off by default, and never on a path that escapes. The resolver client does the same for its own error page, which can echo the payload it was handed — the very personal data the gate exists to protect. An audit `reason` wants a classification anyway: it is queried, not read. `truncateBody` and `truncate` are gone with their test; `logging.DebugfEvery` is the new home for this kind of detail. The regression test drives a consent-manager whose 500 body contains an email address and asserts it does not reach the OTLP payload, while the classification does. It fails if the body is put back into the error. Co-Authored-By: Claude Opus 5 --- internal/consent/client.go | 42 ++++++++++++---------- internal/consent/client_test.go | 10 ------ internal/logging/logging.go | 11 ++++++ internal/ownerresolver/client.go | 17 +++++---- internal/plugin/consent_test.go | 61 ++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 37 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index 64351c7..b5aaa65 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -524,7 +524,7 @@ func (c *Client) lookupParticipantSD(ctx context.Context, did string, forceLogin return "", errParticipantUnauthorized } if status != http.StatusOK { - return "", fmt.Errorf("consent client: participants lookup returned status %d, body: %s", status, truncateBody(body)) + return "", unexpectedStatus("participants lookup", status, body) } participants, err := decodeParticipants(body) @@ -626,8 +626,7 @@ func (c *Client) fetchToken(ctx context.Context) (string, time.Duration, error) 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)) + return "", 0, unexpectedStatus("token service", resp.StatusCode, body) } var out tokenResponse if err := json.Unmarshal(body, &out); err != nil { @@ -660,8 +659,7 @@ func (c *Client) fetchProviderSD(ctx context.Context, token string) (string, err return "", errParticipantUnauthorized } if status != http.StatusOK { - return "", fmt.Errorf("consent client: participant lookup (/me) returned status %d, body: %s", - status, truncateBody(body)) + return "", unexpectedStatus("participant lookup (/me)", status, body) } var out meResponse if err := json.Unmarshal(body, &out); err != nil { @@ -731,8 +729,7 @@ func (c *Client) resolveUserIdentifier(ctx context.Context, subject, providerSD, return "", false, errParticipantUnauthorized } if status != http.StatusOK { - return "", false, fmt.Errorf("consent client: identifier search returned status %d, body: %s", - status, truncateBody(body)) + return "", false, unexpectedStatus("identifier search", status, body) } var out identifierSearchResponse if err := json.Unmarshal(body, &out); err != nil { @@ -880,8 +877,7 @@ func (c *Client) hasGrantedConsent(ctx context.Context, token, userIdentifier st return false, errParticipantUnauthorized } if status != http.StatusOK { - return false, fmt.Errorf("consent client: consents lookup returned status %d, body: %s", - status, truncateBody(body)) + return false, unexpectedStatus("consents lookup", status, body) } var out participantConsentsResponse if err := json.Unmarshal(body, &out); err != nil { @@ -924,13 +920,23 @@ func (c *Client) do(httpReq *http.Request) (statusCode int, body []byte, err err return resp.StatusCode, body, nil } -// maxBodyLogLength bounds error-body length in messages. -const maxBodyLogLength = 256 - -// truncateBody returns the response body as a string, truncated to maxBodyLogLength. -func truncateBody(body []byte) string { - if len(body) <= maxBodyLogLength { - return string(body) - } - return string(body[:maxBodyLogLength]) + "...(truncated)" +// unexpectedStatus builds the error for an unexpected response status from a +// dependency, and sends the response BODY to a debug log rather than into the +// error. +// +// These errors do not stay in the process: the plugin wraps them into the +// decision reason, which is exported to the audit sink and written to stdout. A +// consent-manager 500 that echoes the user identifier in its body would +// therefore land in both — and truncating it, as an earlier version did, is not +// redaction: the first surviving characters of a JSON error body are usually +// exactly the part with the identifiers in it. +// +// The error text is instead a stable, low-cardinality classification, which is +// what an audit reason wants anyway — it is queried, not read. The body is still +// available at debug level, rate-limited per operation so a failing dependency +// cannot flood the log with it. +func unexpectedStatus(operation string, status int, body []byte) error { + logging.DebugfEvery("dependency-body:"+operation, + "consent client: %s returned status %d, body: %s", operation, status, logging.Sanitize(string(body))) + return fmt.Errorf("consent client: %s returned status %d", operation, status) } diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 5a4cd4a..ff4611a 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -499,16 +499,6 @@ func TestCheckConsentContextCancellation(t *testing.T) { assert.Contains(t, err.Error(), "HTTP request failed") } -func TestTruncateBody(t *testing.T) { - assert.Equal(t, "short", truncateBody([]byte("short"))) - assert.Equal(t, "", truncateBody([]byte{})) - assert.NotContains(t, truncateBody(make([]byte, maxBodyLogLength)), "...(truncated)") - - long := truncateBody(make([]byte, maxBodyLogLength+100)) - assert.Contains(t, long, "...(truncated)") - assert.Equal(t, maxBodyLogLength+len("...(truncated)"), len(long)) -} - // TestCacheKeyDistinguishesCredentialIdentities verifies that two clients that // differ in any input feeding the cached token or provider self-description get // distinct cache keys. Sharing an entry across credential identities would make diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 49a3436..d4967b4 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -117,6 +117,17 @@ func allow(key string) (bool, uint64) { return true, suppressed } +// DebugfEvery logs at debug level at most once per suppressionInterval for the +// given key, noting how many occurrences were suppressed in between. +// +// This is where a dependency's response body belongs: useful when debugging, +// off by default, and never on a path that escapes into an audit record. +func DebugfEvery(key, template string, args ...interface{}) { + if ok, suppressed := allow(key); ok { + Debugf(template+suppressedSuffix(suppressed), args...) + } +} + // WarnfEvery logs at warn level at most once per suppressionInterval for the // given key, noting how many occurrences were suppressed in between. func WarnfEvery(key, template string, args ...interface{}) { diff --git a/internal/ownerresolver/client.go b/internal/ownerresolver/client.go index cf84c18..bcf024c 100644 --- a/internal/ownerresolver/client.go +++ b/internal/ownerresolver/client.go @@ -24,6 +24,7 @@ package ownerresolver import ( "bytes" + "consent-plugin/internal/logging" "context" "encoding/json" "fmt" @@ -195,7 +196,13 @@ func (c *Client) Resolve(ctx context.Context, res Resource, p Parties, payload [ 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)) + // The body goes to a debug log, not into the error: this error becomes the + // plugin's decision reason, which is exported to the audit sink and + // written to stdout, and a resolver error page can echo the payload it was + // given — which is the personal data the gate exists to protect. + logging.DebugfEvery("resolver-body", + "owner-resolver: status %d, body: %s", resp.StatusCode, logging.Sanitize(string(body))) + return Result{}, fmt.Errorf("owner-resolver: status %d", resp.StatusCode) } var out Result @@ -216,11 +223,3 @@ func describeBody(payload []byte, contentType string) *bodyDescriptor { return &bodyDescriptor{Encoding: encodingOpaque, ContentType: contentType, Size: len(payload)} } } - -func truncate(b []byte) string { - const limit = 256 - if len(b) <= limit { - return string(b) - } - return string(b[:limit]) + "...(truncated)" -} diff --git a/internal/plugin/consent_test.go b/internal/plugin/consent_test.go index c0c7c92..3009333 100644 --- a/internal/plugin/consent_test.go +++ b/internal/plugin/consent_test.go @@ -27,6 +27,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -1547,3 +1548,63 @@ func metricsExposition() string { metrics.Handler().ServeHTTP(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)) return recorder.Body.String() } + +// TestAuditReasonCarriesNoDependencyBody verifies a dependency's response body +// cannot reach the audit sink through the decision reason. +// +// Errors from the consent client become the reason, and the reason is exported. +// A consent-manager 500 that echoes the user's identifier in its body would +// therefore land in the audit record and on stdout — and truncating it, as an +// earlier version did, is not redaction: the surviving prefix of a JSON error +// body is usually exactly the part with the identifiers in it. The reason must +// be a stable classification instead. +func TestAuditReasonCarriesNoDependencyBody(t *testing.T) { + clearContextStore() + consent.ResetCaches() + + const leakedIdentifier = "alice@example.org" + + var mu sync.Mutex + var reasons []string + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + reasons = append(reasons, string(body)) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants", participantRegistryHandler) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + // A dependency echoing personal data in its error page. + _, _ = w.Write([]byte(`{"error":"lookup failed for ` + leakedIdentifier + `","trace":"..."}`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + resolver := newOwnerResolver(t, ownedBy(testOwnerDID)) + defer resolver.Close() + + cfg := newTestConfig(server.URL, resolver.URL+"/resolve") + cfg.AuditEnabled = true + cfg.AuditOTLPEndpoint = collector.URL + cfg.AuditServiceName = "consent-access-audit-leak-test" + + const id = uint32(290) + storeRequest(id) + (&ConsentFilter{}).ResponseFilter(cfg, newMockResponse(id, []byte(`{"id":"x"}`))) + audit.ShutdownAll() + + mu.Lock() + defer mu.Unlock() + require.NotEmpty(t, reasons, "the decision should have been audited") + for _, payload := range reasons { + assert.NotContains(t, payload, leakedIdentifier, + "a dependency response body must not reach the audit sink through the reason") + } + assert.Contains(t, strings.Join(reasons, ""), "identifier search returned status 500", + "the reason should classify the failure instead") +} From afb00031ad90d9db011853ef642043f5bda8b61e Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:51:24 +0200 Subject: [PATCH 37/41] fix(metrics): declare counters as counters and give every family a HELP (N-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `consent_request_contexts_evicted_total` and `consent_audit_events_dropped_total` were registered through `RegisterGauge` and emitted as `# TYPE … gauge`. Both are monotonic, and their `_total` suffix says so: `promtool check metrics` flags the mismatch, and anyone reaching for `rate(…_total[5m])` over a declared gauge is relying on an accident rather than a stated contract. Gauge families also emitted no `# HELP` line at all, unlike the counters and the histogram. - `RegisterCounter` joins `RegisterGauge`; both now take a help string, and the two `_total` callbacks moved across. The constants are renamed to say which they are (`ContextEvictedCounter`, `AuditDroppedCounter`), leaving `ContextStoreSizeGauge` a gauge, which it correctly is — it goes down. - Every callback family emits `# HELP` and its true `# TYPE`. - The redundant `cumulative` local in the histogram rendering is gone, with a comment noting that `counts` is already cumulative because `observe` increments every bucket at or above the value. Co-Authored-By: Claude Opus 5 --- internal/audit/audit.go | 4 +- internal/metrics/metrics.go | 94 ++++++++++++++++++++++---------- internal/metrics/metrics_test.go | 16 ++++-- internal/plugin/context.go | 8 ++- 4 files changed, 85 insertions(+), 37 deletions(-) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 236f38c..ab52101 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -158,7 +158,9 @@ func init() { // An attacker who can generate load can suppress the record of their own // access by filling the queue, so the loss must be alertable, not merely // logged every hundredth event. - metrics.RegisterGauge(metrics.AuditDroppedGauge, func() float64 { return float64(Dropped()) }) + metrics.RegisterCounter(metrics.AuditDroppedCounter, + "Audit events discarded because the export queue was full.", + func() float64 { return float64(Dropped()) }) } // Get returns a shared Emitter for cfg, creating (and starting) one on first use. diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index df7e5d6..ffcda56 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -88,12 +88,25 @@ var ( // scoping was not actually applied. purposeUnconstrained uint64 - // gauges are read at scrape time from whoever owns the number, so this + // callbacks are read at scrape time from whoever owns the number, so this // package never has to be told when a store's size changes. - gaugeMu sync.Mutex - gauges = map[string]func() float64{} + callbackMu sync.Mutex + callbacks = map[string]callbackMetric{} ) +// Prometheus metric types used in the exposition. +const ( + metricTypeCounter = "counter" + metricTypeGauge = "gauge" +) + +// callbackMetric is a value this package does not own, read at scrape time. +type callbackMetric struct { + help string + kind string + read func() float64 +} + // labelPair is a two-label metric key. type labelPair struct{ first, second string } @@ -155,12 +168,28 @@ func RecordPurposeUnconstrained() { purposeUnconstrained++ } -// RegisterGauge publishes a value read at scrape time. The owner of the number -// keeps owning it; this package only asks for it. -func RegisterGauge(name string, read func() float64) { - gaugeMu.Lock() - defer gaugeMu.Unlock() - gauges[name] = read +// RegisterGauge publishes a value that can go up and down, read at scrape time. +// The owner of the number keeps owning it; this package only asks for it. +func RegisterGauge(name, help string, read func() float64) { + registerCallback(name, help, metricTypeGauge, read) +} + +// RegisterCounter publishes a monotonically increasing value, read at scrape +// time. +// +// The distinction from RegisterGauge is not cosmetic: a `_total` series declared +// as a gauge makes `promtool check metrics` complain, and anyone reaching for +// `rate(..._total[5m])` over it is relying on an accident rather than on a +// stated contract. +func RegisterCounter(name, help string, read func() float64) { + registerCallback(name, help, metricTypeCounter, read) +} + +// registerCallback records a scrape-time value of the given Prometheus type. +func registerCallback(name, help, kind string, read func() float64) { + callbackMu.Lock() + defer callbackMu.Unlock() + callbacks[name] = callbackMetric{help: help, kind: kind, read: read} } // Handler serves the metrics in the Prometheus text exposition format. @@ -192,11 +221,11 @@ func render() string { fmt.Fprintf(&out, "# TYPE %s histogram\n", dependencyLatency) for _, dependency := range sortedMapKeys(latency) { h := latency[dependency] - cumulative := uint64(0) for i, bound := range latencyBuckets { - cumulative = h.counts[i] + // counts is already cumulative: observe increments every bucket whose + // bound is at or above the value. fmt.Fprintf(&out, "%s_bucket{dependency=%q,le=%q} %d\n", - dependencyLatency, dependency, strconv.FormatFloat(bound, 'g', -1, 64), cumulative) + dependencyLatency, dependency, strconv.FormatFloat(bound, 'g', -1, 64), h.counts[i]) } fmt.Fprintf(&out, "%s_bucket{dependency=%q,le=\"+Inf\"} %d\n", dependencyLatency, dependency, h.total) fmt.Fprintf(&out, "%s_sum{dependency=%q} %s\n", dependencyLatency, dependency, strconv.FormatFloat(h.sum, 'g', -1, 64)) @@ -204,17 +233,19 @@ func render() string { } mu.Unlock() - gaugeMu.Lock() - names := make([]string, 0, len(gauges)) - for name := range gauges { + callbackMu.Lock() + names := make([]string, 0, len(callbacks)) + for name := range callbacks { names = append(names, name) } sort.Strings(names) for _, name := range names { - fmt.Fprintf(&out, "# TYPE %s gauge\n%s %s\n", name, name, - strconv.FormatFloat(gauges[name](), 'g', -1, 64)) + metric := callbacks[name] + fmt.Fprintf(&out, "# HELP %s %s\n", name, metric.help) + fmt.Fprintf(&out, "# TYPE %s %s\n", name, metric.kind) + fmt.Fprintf(&out, "%s %s\n", name, strconv.FormatFloat(metric.read(), 'g', -1, 64)) } - gaugeMu.Unlock() + callbackMu.Unlock() return out.String() } @@ -248,20 +279,23 @@ func sortedMapKeys(m map[string]*histogram) []string { return keys } -// Gauge names published by the rest of the plugin. +// Metric names published by the rest of the plugin through RegisterGauge and +// RegisterCounter. const ( - // ContextStoreSizeGauge tracks in-flight gated requests. In a healthy runner - // it returns to zero when idle; a floor that keeps rising is the leak. + // ContextStoreSizeGauge tracks in-flight gated requests. It goes up and down, + // so it is a gauge: in a healthy runner it returns to zero when idle, and a + // floor that keeps rising is the leak. ContextStoreSizeGauge = contextStoreSizeMetric - // ContextEvictedGauge counts contexts dropped because they expired or the - // store was full — requests that never reached their response phase. - ContextEvictedGauge = contextEvictedMetric + // ContextEvictedCounter counts contexts dropped because they expired or the + // store was full — requests that never reached their response phase. It only + // increases, so it is a counter. + ContextEvictedCounter = contextEvictedMetric - // AuditDroppedGauge counts audit records lost to a full queue. An attacker + // AuditDroppedCounter counts audit records lost to a full queue. An attacker // who can generate load can suppress the record of their own access, so this - // must be alertable. - AuditDroppedGauge = auditDroppedMetric + // must be alertable. It only increases, so it is a counter. + AuditDroppedCounter = auditDroppedMetric ) // Reset clears every metric. For tests. @@ -273,7 +307,7 @@ func Reset() { purposeUnconstrained = 0 mu.Unlock() - gaugeMu.Lock() - gauges = map[string]func() float64{} - gaugeMu.Unlock() + callbackMu.Lock() + callbacks = map[string]callbackMetric{} + callbackMu.Unlock() } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 2ee80af..3349f0f 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -43,7 +43,8 @@ func TestRenderExposition(t *testing.T) { RecordDependencyCall(DependencyConsentManager, OutcomeSuccess, 20*time.Millisecond) RecordDependencyCall(DependencyConsentManager, OutcomeError, 3*time.Second) - RegisterGauge(ContextStoreSizeGauge, func() float64 { return 7 }) + RegisterGauge(ContextStoreSizeGauge, "Request contexts currently held.", func() float64 { return 7 }) + RegisterCounter(ContextEvictedCounter, "Request contexts evicted.", func() float64 { return 3 }) out := render() @@ -63,6 +64,13 @@ func TestRenderExposition(t *testing.T) { assert.Contains(t, out, "consent_request_context_store_size 7") assert.Contains(t, out, "# TYPE consent_decisions_total counter") assert.Contains(t, out, "# TYPE consent_dependency_duration_seconds histogram") + + // A `_total` series must be declared a counter, or `rate()` over it is an + // accident rather than a contract; and every family needs a HELP line. + assert.Contains(t, out, "# TYPE consent_request_context_store_size gauge") + assert.Contains(t, out, "# TYPE consent_request_contexts_evicted_total counter") + assert.Contains(t, out, "# HELP consent_request_context_store_size Request contexts currently held.") + assert.Contains(t, out, "# HELP consent_request_contexts_evicted_total Request contexts evicted.") } // TestGaugesAreReadAtScrapeTime verifies a gauge reflects the current value @@ -72,7 +80,7 @@ func TestGaugesAreReadAtScrapeTime(t *testing.T) { t.Cleanup(Reset) size := 0 - RegisterGauge(ContextStoreSizeGauge, func() float64 { return float64(size) }) + RegisterGauge(ContextStoreSizeGauge, "Request contexts currently held.", func() float64 { return float64(size) }) assert.Contains(t, render(), "consent_request_context_store_size 0") size = 42 @@ -104,8 +112,8 @@ func TestRenderIsStable(t *testing.T) { } RecordDependencyCall(DependencyOwnerResolver, OutcomeSuccess, time.Millisecond) RecordDependencyCall(DependencyConsentManager, OutcomeSuccess, time.Millisecond) - RegisterGauge(AuditDroppedGauge, func() float64 { return 1 }) - RegisterGauge(ContextStoreSizeGauge, func() float64 { return 2 }) + RegisterCounter(AuditDroppedCounter, "Audit events dropped.", func() float64 { return 1 }) + RegisterGauge(ContextStoreSizeGauge, "Request contexts currently held.", func() float64 { return 2 }) first := render() for i := 0; i < 20; i++ { diff --git a/internal/plugin/context.go b/internal/plugin/context.go index 251a7ac..aeb4198 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -115,8 +115,12 @@ var ( func init() { // Publish the store's size and eviction count so a leak is observable rather // than only inferable from memory growth. - metrics.RegisterGauge(metrics.ContextStoreSizeGauge, func() float64 { return float64(RequestContextStoreSize()) }) - metrics.RegisterGauge(metrics.ContextEvictedGauge, func() float64 { return float64(RequestContextsEvicted()) }) + metrics.RegisterGauge(metrics.ContextStoreSizeGauge, + "Request contexts currently held, i.e. gated requests in flight.", + func() float64 { return float64(RequestContextStoreSize()) }) + metrics.RegisterCounter(metrics.ContextEvictedCounter, + "Request contexts evicted because they expired or the store was full.", + func() float64 { return float64(RequestContextsEvicted()) }) } // startContextJanitor launches the background sweep exactly once. It is started From 82974a51effa2c0c76e39c30b2d1c424fbae3085 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 11:55:08 +0200 Subject: [PATCH 38/41] =?UTF-8?q?test:=20close=20the=20residual=20coverage?= =?UTF-8?q?=20gaps=20and=20benchmark=20the=20overflow=20path=20(=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review listed three small gaps, all in code that is live rather than incidental. - `audit.Dropped()` was at 0% despite being read by the registered metric callback — the only thing that makes suppression of an access record visible. Now covered, including that it aggregates across emitters. - `fetchProviderSD` was at 65%. It runs before any consent check, so a failure there takes the whole exchange down; its 401, 5xx, unparseable-body and empty-selfDescriptionURL branches are now pinned, along with an assertion that the dependency's response body does not travel in the error (N-5). The static provider-SD override is covered too, asserting it makes no HTTP call at all. - No benchmark existed for the store under load, so N-3's fix was unmeasured. `BenchmarkStoreRequestContextAtCap` fills the store to `MaxRequestContexts` and measures the overflow path — the one that only engages under the leak or abort-flood the cap exists to contain, i.e. exactly when the gate is already under pressure. On this machine: eviction batch 1 (the old behaviour) ~3,000,000 ns/op eviction batch 1% of cap (current) ~17,000 ns/op about a 175x difference, and the batched figure sits within ~2x of the uncontended `BenchmarkStoreRequestContext`. That is the latency cliff the finding described, now measurable rather than argued. Total coverage 89.0% -> 91.1%. Co-Authored-By: Claude Opus 5 --- internal/audit/audit_test.go | 22 +++++++ internal/consent/client_test.go | 87 +++++++++++++++++++++++++++ internal/plugin/context_bench_test.go | 68 +++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 internal/plugin/context_bench_test.go diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index d7c2155..ec16520 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -252,3 +252,25 @@ func TestDroppedIsObservable(t *testing.T) { assert.Equal(t, uint64(4), e.Dropped(), "one event fits the queue, the rest are dropped and counted") } + +// TestDroppedAggregatesAcrossEmitters verifies the package-level Dropped — the +// value the registered metric callback reads, and therefore the only thing that +// makes suppression of an access record visible — sums every emitter. +func TestDroppedAggregatesAcrossEmitters(t *testing.T) { + ShutdownAll() // start from a clean registry + t.Cleanup(ShutdownAll) + + assert.Zero(t, Dropped(), "a fresh registry has dropped nothing") + + // Two emitters whose workers are already stopped, so nothing drains them. + for _, serviceName := range []string{"audit-a", "audit-b"} { + e := Get(Config{Endpoint: "http://collector.invalid:4318", ServiceName: serviceName}) + e.Shutdown() + for i := 0; i < defaultQueueSize+3; i++ { + e.Emit(Event{Time: time.Now(), Decision: "allow"}) + } + } + + assert.Equal(t, uint64(6), Dropped(), + "three events past the queue size are dropped by each of the two emitters") +} diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index ff4611a..5a1a7a2 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -843,3 +843,90 @@ func TestCheckConsent_PurposeScoped(t *testing.T) { }) } } + +// TestFetchProviderSD_Failures covers the derivation of the provider +// self-description from /participants/me. It runs before any consent check, so +// a failure here takes the whole exchange down (fail-closed) — the branches are +// worth pinning. +func TestFetchProviderSD_Failures(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr string + wantNoBody bool + }{ + { + name: "a 401 is reported as unauthorized so the caller can refresh", + status: http.StatusUnauthorized, + body: `{}`, + // CheckConsent maps the retried-and-still-401 case to this message. + wantErr: "participant token rejected (401)", + }, + { + name: "a 500 is classified without its body", + status: http.StatusInternalServerError, + body: `{"error":"boom for alice@example.org"}`, + wantErr: "participant lookup (/me) returned status 500", + }, + { + name: "an unparseable body errors", + status: http.StatusOK, + body: `not json`, + wantErr: "failed to unmarshal /me response", + }, + { + name: "an empty selfDescriptionURL errors", + status: http.StatusOK, + body: `{"selfDescriptionURL":""}`, + wantErr: "/me returned no selfDescriptionURL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetCredCache() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/participants/me", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + }) + mux.HandleFunc(tokenServicePath, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "tok", "token_type": "Bearer", "expires_in": 3600, + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", TokenServiceURL: srv.URL + tokenServicePath, TokenAudience: testAudience}) + + _, err := c.CheckConsent(context.Background(), ConsentRequest{Subject: "did:key:z", Consumer: testConsumerSD}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.NotContains(t, err.Error(), "alice@example.org", + "a dependency response body must not travel in the error") + }) + } +} + +// TestProviderSelfDescription_StaticOverride verifies a configured provider SD +// is returned without any HTTP call at all. +func TestProviderSelfDescription_StaticOverride(t *testing.T) { + resetCredCache() + m := &mockCM{userID: "uid-1", statuses: []string{"granted"}} + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ParticipantToken: "static", ProviderSD: "http://catalog/participants/static"}) + + sd, err := c.ProviderSelfDescription(context.Background()) + require.NoError(t, err) + assert.Equal(t, "http://catalog/participants/static", sd) + + m.mu.Lock() + defer m.mu.Unlock() + assert.Equal(t, 0, m.meCalls, "a static provider SD must not trigger /me") + assert.Equal(t, 0, m.tokenCalls, "a static token must not trigger the token service") +} diff --git a/internal/plugin/context_bench_test.go b/internal/plugin/context_bench_test.go new file mode 100644 index 0000000..02ce52a --- /dev/null +++ b/internal/plugin/context_bench_test.go @@ -0,0 +1,68 @@ +/* + * 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 ( + "fmt" + "testing" + "time" +) + +// fillContextStore populates the store with n live entries of ascending age. +func fillContextStore(n int) { + now := time.Now() + requestContextMu.Lock() + defer requestContextMu.Unlock() + requestContextStore = make(map[string]storedRequestContext, n) + for i := 0; i < n; i++ { + requestContextStore[fmt.Sprintf("live-%d", i)] = storedRequestContext{ + ctx: &RequestContext{Path: "/x"}, + storedAt: now.Add(time.Duration(i) * time.Microsecond), + } + } +} + +// BenchmarkStoreRequestContext measures the ordinary path: a store with room. +func BenchmarkStoreRequestContext(b *testing.B) { + fillContextStore(0) + b.Cleanup(clearContextStore) + + ctx := &RequestContext{Method: "GET", Path: "/data"} + for i := 0; b.Loop(); i++ { + StoreRequestContext(fmt.Sprintf("key-%d", i), ctx) + } +} + +// BenchmarkStoreRequestContextAtCap measures the overflow path, which is the +// one that matters: the store only reaches its cap under the leak or the +// abort-flood the cap exists to contain, so this is the cost the gate pays +// exactly when it is already under pressure. +// +// Evicting one entry per overflow made every request at the cap pay two +// whole-map scans under the store's mutex. Evicting a batch amortises that over +// contextEvictionBatch requests, so the per-request cost here should stay close +// to the uncontended case above rather than scaling with MaxRequestContexts. +func BenchmarkStoreRequestContextAtCap(b *testing.B) { + fillContextStore(MaxRequestContexts) + b.Cleanup(clearContextStore) + + ctx := &RequestContext{Method: "GET", Path: "/data"} + for i := 0; b.Loop(); i++ { + StoreRequestContext(fmt.Sprintf("overflow-%d", i), ctx) + } +} From 484616a662bde3e14c6b6b37d4b6487f909f7c9e Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 12:01:50 +0200 Subject: [PATCH 39/41] fix(dev): repair the dev stack, which validated but could not start (M-6 residual) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose config -q` passing is not the same as the stack running, and actually running it — to settle the M-1 Content-Length question end to end — turned up two things that made it fail: - **`bitnami/etcd:3.5` no longer resolves.** Bitnami retired their public catalog, so the very first `docker compose up` died on `manifest unknown` before anything started. Switched to the upstream `quay.io/coreos/etcd`, which needs no auth-disabling env var because it is open by default — fine for a local stack, which this is, and it is not a deployment example. - **WireMock rejected every stub file.** The `"//"` keys I used as comments are unknown top-level fields on a StubMapping, so WireMock exited at startup with `Unrecognized field "//"`. The explanations now live in the mapping's own `name` and `metadata` fields, which is what they are for. The second failure was quiet in a way worth fixing rather than just correcting: `docker compose ps` hides an exited container, so a dead mock surfaced only as an unexplained deny from the gate several steps later, with a DNS error buried in the runner's log. The mock now has a healthcheck and the runner waits on it, so a broken stub fails where it happens. Also wires `CONSENT_METRICS_ADDRESS` into the runner and publishes port 9091, so the metrics endpoint is exercisable in the dev stack rather than only in tests. Co-Authored-By: Claude Opus 5 --- README.md | 3 +++ dev/mocks/consents.json | 19 ++++++++++++++----- dev/mocks/identifier-search.json | 18 ++++++++++++++---- dev/mocks/owner-resolver.json | 22 ++++++++++++++++++---- dev/mocks/participants-me.json | 18 ++++++++++++++---- dev/mocks/participants.json | 19 +++++++++++++++---- dev/mocks/token-service.json | 20 ++++++++++++++++---- docker-compose.yaml | 31 +++++++++++++++++++++++++------ 8 files changed, 119 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9268afb..5463cf9 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ Collector for the audit log: | `upstream` | echo service standing in for the personal-data API | — | | `otel-collector` | receives the access-decision audit log | `4318` | +The runner also serves Prometheus metrics on `9091` (`CONSENT_METRICS_ADDRESS` is +set for it in the compose file). + Then create the gated route: ```bash diff --git a/dev/mocks/consents.json b/dev/mocks/consents.json index 7d534d4..af468a5 100644 --- a/dev/mocks/consents.json +++ b/dev/mocks/consents.json @@ -1,15 +1,24 @@ { - "//": "GET /consents/participants/{id}?receipt=true — the owner's consents. Granted TO dev-consumer, which is what makes the check pass; change the consumer here to watch the gate deny.", - "request": { "method": "GET", "urlPathPattern": "/v1/consents/participants/.*" }, + "name": "owner consents", + "metadata": { + "comment": "GET /consents/participants/{id}?receipt=true - the owner's consents. Granted TO dev-consumer, which is what makes the check pass; change the consumer here, or the status to revoked, to watch the gate deny." + }, + "request": { + "method": "GET", + "urlPathPattern": "/v1/consents/participants/.*" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, + "headers": { + "Content-Type": "application/json" + }, "jsonBody": { "consents": [ { "status": "granted", - "consumer": { "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" }, - "data": [{ "resource": "http://mock:8080/resources/personal-profile" }] + "consumer": { + "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" + } } ] } diff --git a/dev/mocks/identifier-search.json b/dev/mocks/identifier-search.json index 2383bce..49c7b5a 100644 --- a/dev/mocks/identifier-search.json +++ b/dev/mocks/identifier-search.json @@ -1,9 +1,19 @@ { - "//": "POST /users/identifier/search — resolves the data owner DID (sent as the user email) to the provider-scoped user identifier.", - "request": { "method": "POST", "url": "/v1/users/identifier/search" }, + "name": "identifier search", + "metadata": { + "comment": "POST /users/identifier/search - resolves the data owner DID (sent as the user email) to the provider-scoped user identifier." + }, + "request": { + "method": "POST", + "url": "/v1/users/identifier/search" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, - "jsonBody": { "userIdentifier": "dev-user-identifier" } + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "userIdentifier": "dev-user-identifier" + } } } diff --git a/dev/mocks/owner-resolver.json b/dev/mocks/owner-resolver.json index d64de99..2746c68 100644 --- a/dev/mocks/owner-resolver.json +++ b/dev/mocks/owner-resolver.json @@ -1,13 +1,27 @@ { - "//": "POST /resolve — the OwnerResolver. It answers from the DATA who the owner is; here it always reports the same owner so the stack has a working happy path.", - "request": { "method": "POST", "url": "/resolve" }, + "name": "owner resolver", + "metadata": { + "comment": "POST /resolve - the OwnerResolver. It answers from the DATA who the owner is; here it always reports the same owner so the stack has a working happy path." + }, + "request": { + "method": "POST", + "url": "/resolve" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, + "headers": { + "Content-Type": "application/json" + }, "jsonBody": { "consentRequired": true, "claims": [ - { "selector": { "type": "jsonpath", "value": "$.id" }, "ownerId": "did:key:zDevOwner" } + { + "selector": { + "type": "jsonpath", + "value": "$.id" + }, + "ownerId": "did:key:zDevOwner" + } ] } } diff --git a/dev/mocks/participants-me.json b/dev/mocks/participants-me.json index 2c60670..25e6944 100644 --- a/dev/mocks/participants-me.json +++ b/dev/mocks/participants-me.json @@ -1,9 +1,19 @@ { - "//": "GET /participants/me — the provider self-description the identifier search is scoped by.", - "request": { "method": "GET", "url": "/v1/participants/me" }, + "name": "provider self-description", + "metadata": { + "comment": "GET /participants/me - the provider self-description the identifier search is scoped by." + }, + "request": { + "method": "GET", + "url": "/v1/participants/me" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, - "jsonBody": { "selfDescriptionURL": "http://mock:8080/participants/dev-provider" } + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "selfDescriptionURL": "http://mock:8080/participants/dev-provider" + } } } diff --git a/dev/mocks/participants.json b/dev/mocks/participants.json index a8578ab..1be4d59 100644 --- a/dev/mocks/participants.json +++ b/dev/mocks/participants.json @@ -1,11 +1,22 @@ { - "//": "GET /participants — the registry that maps the consumer DID from the token to the self-description URL a contract names its parties by.", - "request": { "method": "GET", "url": "/v1/participants" }, + "name": "participant registry", + "metadata": { + "comment": "GET /participants - the registry that maps the consumer DID from the token to the self-description URL a contract names its parties by." + }, + "request": { + "method": "GET", + "url": "/v1/participants" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, + "headers": { + "Content-Type": "application/json" + }, "jsonBody": [ - { "did": "did:key:zDevConsumer", "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" } + { + "did": "did:key:zDevConsumer", + "selfDescriptionURL": "http://mock:8080/participants/dev-consumer" + } ] } } diff --git a/dev/mocks/token-service.json b/dev/mocks/token-service.json index 1997546..917647b 100644 --- a/dev/mocks/token-service.json +++ b/dev/mocks/token-service.json @@ -1,9 +1,21 @@ { - "//": "The participant-local OID4VP token service (the consent-facade's POST /internal/tokens). It mints the short-lived participant token the plugin uses for the consents lookup.", - "request": { "method": "POST", "url": "/internal/tokens" }, + "name": "participant token service", + "metadata": { + "comment": "The participant-local OID4VP token service (the consent-facade's POST /internal/tokens). It mints the short-lived participant token the plugin uses for the consents lookup." + }, + "request": { + "method": "POST", + "url": "/internal/tokens" + }, "response": { "status": 200, - "headers": { "Content-Type": "application/json" }, - "jsonBody": { "access_token": "dev-participant-token", "token_type": "Bearer", "expires_in": 3600 } + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "access_token": "dev-participant-token", + "token_type": "Bearer", + "expires_in": 3600 + } } } diff --git a/docker-compose.yaml b/docker-compose.yaml index 60f6812..0d0f083 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,13 +14,17 @@ # before either process can bind, and the runner then fails to start. services: + # The upstream etcd image rather than bitnami/etcd, which was retired from the + # public catalog and no longer resolves — `docker compose up` failed on the + # pull. This one needs no auth-disabling env var: it is open by default, which + # is fine for a local stack and is not a deployment example. etcd: - image: bitnami/etcd:3.5 - environment: - ETCD_ENABLE_V2: "true" - ALLOW_NONE_AUTHENTICATION: "yes" - ETCD_ADVERTISE_CLIENT_URLS: "http://etcd:2379" - ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379" + image: quay.io/coreos/etcd:v3.5.17 + command: + - etcd + - --name=etcd0 + - --advertise-client-urls=http://etcd:2379 + - --listen-client-urls=http://0.0.0.0:2379 ports: - "2379:2379" @@ -54,6 +58,8 @@ services: depends_on: socket-init: condition: service_completed_successfully + mock: + condition: service_healthy volumes: - runner-socket:/opt/runner environment: @@ -63,6 +69,10 @@ services: CONSENT_KEY: "dev-consent-key" CONSENT_TOKEN_SERVICE_URL: "http://mock:8080/internal/tokens" CONSENT_AUDIT_OTLP_ENDPOINT: "http://otel-collector:4318" + # Prometheus metrics, off unless an address is set. + CONSENT_METRICS_ADDRESS: ":9091" + ports: + - "9091:9091" # Prometheus metrics restart: on-failure # Mock consent-manager, OwnerResolver and participant token service. The @@ -74,6 +84,15 @@ services: - ./dev/mocks:/home/wiremock/mappings:ro ports: - "8081:8080" + # A malformed stub makes WireMock exit at startup, and without a healthcheck + # that shows up only as an unexplained deny from the gate several steps + # later. Depending services wait for this instead. + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s # The upstream whose responses are gated. It echoes the request back as JSON, # which is enough for the OwnerResolver stub to be asked about a payload. From 91b40cd3126912d2751a77e63321b5e4929e71d9 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 13:19:21 +0200 Subject: [PATCH 40/41] move image to seamware --- .github/workflows/README.md | 2 +- .github/workflows/pre-release.yml | 2 +- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 2 +- Makefile | 2 +- README.md | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 80e8a7b..ec41049 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -22,7 +22,7 @@ structure: quality gates run on every PR and on `main`, and releases are Each release produces: -- **Container image** — `quay.io/wi_stefan/consent-plugin:` (plus `:latest` +- **Container image** — `quay.io/seamware/consent-plugin:` (plus `:latest` and `:`), multi-arch `linux/amd64,linux/arm64`. **This is the primary deployment artifact**: the APISIX deployment's init container copies `/app/go-runner` out of the image into the `ext-plugin` volume diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 6af593c..5b70510 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -20,7 +20,7 @@ concurrency: env: REGISTRY: quay.io - REPOSITORY: wi_stefan + REPOSITORY: seamware IMAGE_NAME: consent-plugin jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c34478..8431b1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ on: env: REGISTRY: quay.io - REPOSITORY: wi_stefan + REPOSITORY: seamware IMAGE_NAME: consent-plugin jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6992844..e29fe4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ On merge to `main`, `main.yml` re-runs the gates and calls `release.yml`, which: 1. computes the next version from the label, 2. builds, scans and pushes the multi-arch image to - `quay.io/wi_stefan/consent-plugin`, and + `quay.io/seamware/consent-plugin`, and 3. publishes a GitHub Release with the `go-runner` binaries. While a PR is open, `pre-release.yml` publishes a `…-PRE-` image and a GitHub diff --git a/Makefile b/Makefile index 3517b9f..35d6317 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ BINARY_NAME := go-runner # Docker image configuration -DOCKER_IMAGE := quay.io/wi_stefan/consent-plugin +DOCKER_IMAGE := quay.io/seamware/consent-plugin DOCKER_TAG := 0.0.1 # Go build flags diff --git a/README.md b/README.md index 5463cf9..cdcecb2 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ versioning rules. Each release publishes: - a multi-arch (`linux/amd64,arm64`) image - `quay.io/wi_stefan/consent-plugin:` (also `:latest`, `:`), and + `quay.io/seamware/consent-plugin:` (also `:latest`, `:`), and - standalone `go-runner` binaries (`consent-plugin-linux-{amd64,arm64}`) on the GitHub Release. @@ -307,7 +307,7 @@ APISIX launches it as the external plugin runner: ```yaml initContainers: - name: install-consent-plugin - image: quay.io/wi_stefan/consent-plugin: + image: quay.io/seamware/consent-plugin: command: ["cp", "/app/go-runner", "/ext-plugin/go-runner"] volumeMounts: - name: ext-plugin-bin From 74a771fc794f9641239906708b1f594ad1fd3b10 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 27 Aug 2026 13:30:22 +0200 Subject: [PATCH 41/41] fixed ci --- .github/workflows/security-analysis.yml | 16 +++++++++++++++- internal/plugin/config.go | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml index 9c2a0c0..9cccea0 100644 --- a/.github/workflows/security-analysis.yml +++ b/.github/workflows/security-analysis.yml @@ -12,11 +12,25 @@ name: Security Analysis # boundary. Keep GOSEC_VERSION and the golangci-lint version in .gitea/workflows/ # and .github/workflows/style-guide.yml in step, so the two pipelines cannot # disagree about whether the code passes. +# +# A pin still has to be able to BUILD. gosec is installed from source with the +# toolchain from go.mod, so its own dependency tree must compile under that Go +# version: gosec v2.21.4 pinned golang.org/x/tools v0.25.0, which reaches into +# the internal layout of go/token via unsafe and guards it with a deliberate +# compile-time tripwire ("if the size of token.FileSet changes, this will fail to +# compile"). Go 1.26 changed that layout, so the guard fired and the step could +# never build: +# +# x/tools@v0.25.0/internal/tokeninternal/tokeninternal.go:64:9: +# invalid array length -delta * delta (constant -256 of type int64) +# +# Any pre-Go-1.26 x/tools is affected, so a scanner pin must be advanced together +# with the toolchain in go.mod. v2.29.0 builds on golang.org/x/tools v0.49.0. on: workflow_call: env: - GOSEC_VERSION: v2.21.4 + GOSEC_VERSION: v2.29.0 jobs: govulncheck: diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 2c84fa3..2c9ec25 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -140,6 +140,11 @@ const ( // EnvTokenServiceURL supplies TokenServiceURL (the participant-local OID4VP // token service). + // + // #nosec G101 -- this is the NAME of an environment variable, not a + // credential; it trips the hardcoded-credentials heuristic only because it + // contains "TOKEN". The value it names is read from the environment at + // ParseConfig time (see applyEnv) and never appears in the source. EnvTokenServiceURL = "CONSENT_TOKEN_SERVICE_URL" // EnvAuditOTLPEndpoint supplies AuditOTLPEndpoint (the OTLP/HTTP Collector