From a3e14eeddbceb21d0e0e4b59d4644726fad069af Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Mon, 31 Aug 2026 07:54:41 +0200 Subject: [PATCH] fix client --- internal/consent/client.go | 22 ++++++++++-- internal/consent/client_test.go | 62 ++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/internal/consent/client.go b/internal/consent/client.go index b5aaa65..8e3cef4 100644 --- a/internal/consent/client.go +++ b/internal/consent/client.go @@ -762,7 +762,12 @@ type consentRecord struct { // used for the participant the data is released to; either may be present. Consumer participantRef `json:"consumer"` DataConsumer participantRef `json:"dataConsumer"` - Purposes []struct { + // Recipients names the parties the data is released to, by self-description + // URL. It is the only place the consumer appears in a form the plugin can + // compare: on the `?receipt=true` records, `dataConsumer` is a bare Mongo id, + // which matches nothing the plugin holds. + Recipients []string `json:"recipients"` + Purposes []struct { ID string `json:"_id"` Purpose string `json:"purpose"` } `json:"purposes"` @@ -813,8 +818,21 @@ func (p participantRef) matches(identity string) bool { } // grantedTo reports whether the consent was granted to the given consumer. +// +// The consumer arrives as a self-description URL (mapped from the presented +// credential's DID). On a `?receipt=true` record the consent-manager names the +// consumer only as an internal id in `dataConsumer`, so the SD comparison has to +// fall back to `recipients`. +// +// Only the FIRST recipient counts, because that is exactly how the authority +// itself reads the field: the consent-manager resolves the data consumer as +// `recipients[0]` (consentsController). Matching any recipient would authorise a +// party the authority would not treat as the consumer. func (r consentRecord) grantedTo(consumer string) bool { - return r.Consumer.matches(consumer) || r.DataConsumer.matches(consumer) + if r.Consumer.matches(consumer) || r.DataConsumer.matches(consumer) { + return true + } + return len(r.Recipients) > 0 && r.Recipients[0] != "" && r.Recipients[0] == consumer } // coversPurpose reports whether the consent covers the given processing purpose. diff --git a/internal/consent/client_test.go b/internal/consent/client_test.go index 5a1a7a2..64e0620 100644 --- a/internal/consent/client_test.go +++ b/internal/consent/client_test.go @@ -53,9 +53,12 @@ type mockCM struct { 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 + // consentUsesRecipients emits the REAL ?receipt=true shape: `dataConsumer` as a + // bare Mongo id plus `recipients: []`, instead of an expanded `consumer`. + consentUsesRecipients bool + 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 tokenCalls, meCalls, searchCalls, consentsCalls int lastConsentKey, lastSearchEmail, lastSearchSD string @@ -126,6 +129,7 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { fail := m.failFirstConsents sts := append([]string(nil), m.statuses...) res := append([][]string(nil), m.resourcesPerConsent...) + useRecipients := m.consentUsesRecipients consumer := m.consentConsumer if consumer == "" { consumer = testConsumerSD @@ -142,7 +146,15 @@ func newMockCM(t *testing.T, m *mockCM) *httptest.Server { for i, s := range sts { consent := map[string]interface{}{"status": s} if !omitConsumer { - consent["consumer"] = map[string]string{"selfDescriptionURL": consumer} + if useRecipients { + // The real ?receipt=true shape: the consumer appears as a bare + // Mongo id in dataConsumer, and only `recipients` carries the + // self-description URL the plugin can compare against. + consent["dataConsumer"] = "6a95131a0a7640f7f6dee2d2" + consent["recipients"] = []string{consumer} + } else { + consent["consumer"] = map[string]string{"selfDescriptionURL": consumer} + } } if len(purposes) > 0 { ps := make([]map[string]string, 0, len(purposes)) @@ -308,6 +320,48 @@ func TestCheckConsent(t *testing.T) { // TestCheckConsent_ClientCredentials verifies the full client-credentials flow: // login for a token, derive the provider SD from /me, then run the two calls. +// TestCheckConsent_ConsumerNamedOnlyInRecipients is the shape the consent-manager +// actually returns from `?receipt=true`: the consumer appears as a bare Mongo id in +// `dataConsumer`, and the self-description URL the plugin holds appears only in +// `recipients`. Matching on consumer/dataConsumer alone denies a perfectly valid +// consent - which is what made step 4 of the demo answer 403 with a granted consent +// in place. +func TestCheckConsent_ConsumerNamedOnlyInRecipients(t *testing.T) { + const providerSD = "http://consent-facade:8080/participants/org-1" + resetCredCache() + m := &mockCM{ + userID: "uid-1", selfDescriptionURL: providerSD, statuses: []string{"granted"}, + consentUsesRecipients: true, + } + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static", ProviderSD: providerSD}) + + resp, err := c.CheckConsent(context.Background(), + ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD}) + require.NoError(t, err) + assert.Equal(t, DecisionAllow, resp.Decision, + "a consent whose consumer is named in recipients must be honoured") +} + +// TestCheckConsent_RecipientsNameADifferentConsumer guards the other direction: the +// recipients fallback must not authorise a participant the consent was not granted to. +func TestCheckConsent_RecipientsNameADifferentConsumer(t *testing.T) { + const providerSD = "http://consent-facade:8080/participants/org-1" + resetCredCache() + m := &mockCM{ + userID: "uid-1", selfDescriptionURL: providerSD, statuses: []string{"granted"}, + consentUsesRecipients: true, consentConsumer: "http://consent-facade:8080/participants/someone-else", + } + srv := newMockCM(t, m) + c := NewClient(ClientConfig{BaseURL: srv.URL, ConsentKey: "ck", ParticipantToken: "static", ProviderSD: providerSD}) + + resp, err := c.CheckConsent(context.Background(), + ConsentRequest{Subject: "alice-42", Consumer: testConsumerSD}) + require.NoError(t, err) + assert.Equal(t, DecisionDeny, resp.Decision, + "a consent granted to another participant is not authority for this one") +} + func TestCheckConsent_TokenService(t *testing.T) { resetCredCache() m := &mockCM{userID: "uid-1", statuses: []string{"granted"}, selfDescriptionURL: "http://facade/participants/derived"}