diff --git a/config/account.go b/config/account.go index a54da82d212..310fd021dce 100644 --- a/config/account.go +++ b/config/account.go @@ -159,6 +159,7 @@ type AccountGDPR struct { PurposeConfigs map[consentconstants.Purpose]*AccountGDPRPurpose PurposeOneTreatment AccountGDPRPurposeOneTreatment `mapstructure:"purpose_one_treatment" json:"purpose_one_treatment"` SpecialFeature1 AccountGDPRSpecialFeature `mapstructure:"special_feature1" json:"special_feature1"` + EEACountries []string `mapstructure:"eea_countries" json:"eea_countries"` } // EnabledForChannelType indicates whether GDPR is turned on at the account level for the specified channel type diff --git a/exchange/exchange.go b/exchange/exchange.go index 9ab91ee9ea3..daddf9debc2 100644 --- a/exchange/exchange.go +++ b/exchange/exchange.go @@ -316,8 +316,11 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog recordImpMetrics(r.BidRequestWrapper, e.me) + // Retrieve EEA countries configuration from either host or account settings + eeaCountries := selectEEACountries(e.privacyConfig.GDPR.EEACountries, r.Account.GDPR.EEACountries) + // Make our best guess if GDPR applies - gdprDefaultValue := e.parseGDPRDefaultValue(r.BidRequestWrapper) + gdprDefaultValue := e.parseGDPRDefaultValue(r.BidRequestWrapper, eeaCountries) gdprSignal, err := getGDPR(r.BidRequestWrapper) if err != nil { return nil, err @@ -571,7 +574,7 @@ func buildMultiBidMap(prebid *openrtb_ext.ExtRequestPrebid) map[string]openrtb_e return multiBidMap } -func (e *exchange) parseGDPRDefaultValue(r *openrtb_ext.RequestWrapper) gdpr.Signal { +func (e *exchange) parseGDPRDefaultValue(r *openrtb_ext.RequestWrapper, eeaCountries []string) gdpr.Signal { gdprDefaultValue := e.gdprDefaultValue var geo *openrtb2.Geo @@ -582,12 +585,11 @@ func (e *exchange) parseGDPRDefaultValue(r *openrtb_ext.RequestWrapper) gdpr.Sig } if geo != nil { - // If we have a country set, and it is on the list, we assume GDPR applies if not set on the request. - // Otherwise we assume it does not apply as long as it appears "valid" (is 3 characters long). - if _, found := e.privacyConfig.GDPR.EEACountriesMap[strings.ToUpper(geo.Country)]; found { + // If the country is in the EEA list, GDPR applies. + // Otherwise, if the country code is properly formatted (3 characters), GDPR does not apply. + if isEEACountry(geo.Country, eeaCountries) { gdprDefaultValue = gdpr.SignalYes } else if len(geo.Country) == 3 { - // The country field is formatted properly as a three character country code gdprDefaultValue = gdpr.SignalNo } } @@ -1622,3 +1624,17 @@ func setSeatNonBid(bidResponseExt *openrtb_ext.ExtBidResponse, seatNonBidBuilder bidResponseExt.Prebid.SeatNonBid = seatNonBidBuilder.Slice() return bidResponseExt } + +func isEEACountry(country string, eeaCountries []string) bool { + if len(eeaCountries) == 0 { + return false + } + + country = strings.ToUpper(country) + for _, c := range eeaCountries { + if strings.ToUpper(c) == country { + return true + } + } + return false +} diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go index 87b53b101e0..7edbec64d7e 100644 --- a/exchange/exchange_test.go +++ b/exchange/exchange_test.go @@ -2114,9 +2114,10 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) { } var s struct{} - eeac := make(map[string]struct{}) - for _, c := range []string{"FIN", "FRA", "GUF"} { - eeac[c] = s + eeacMap := make(map[string]struct{}) + eeac := []string{"FIN", "FRA", "GUF"} + for _, c := range eeac { + eeacMap[c] = s } var gdprDefaultValue string @@ -2136,7 +2137,8 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) { GDPR: config.GDPR{ Enabled: spec.GDPREnabled, DefaultValue: gdprDefaultValue, - EEACountriesMap: eeac, + EEACountries: eeac, + EEACountriesMap: eeacMap, TCF2: config.TCF2{ Enabled: spec.GDPREnabled, }, @@ -2192,6 +2194,7 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) { PriceFloors: config.AccountPriceFloors{Enabled: spec.AccountFloorsEnabled, EnforceDealFloors: spec.AccountEnforceDealFloors}, Privacy: spec.AccountPrivacy, Validations: spec.AccountConfigBidValidation, + GDPR: config.AccountGDPR{EEACountries: spec.AccountEEACountries}, }, UserSyncs: mockIdFetcher(spec.IncomingRequest.Usersyncs), ImpExtInfoMap: impExtInfoMap, @@ -5518,6 +5521,7 @@ type exchangeSpec struct { Server exchangeServer `json:"server,omitempty"` AccountPrivacy config.AccountPrivacy `json:"accountPrivacy,omitempty"` ORTBVersion map[string]string `json:"ortbversion"` + AccountEEACountries []string `json:"account_eea_countries"` } type multiBidSpec struct { @@ -6351,6 +6355,61 @@ func TestBidsToUpdate(t *testing.T) { } } +func TestIsEEACountry(t *testing.T) { + eeaCountries := []string{"FRA", "DEU", "ITA", "ESP", "NLD"} + + tests := []struct { + name string + country string + eeaList []string + expected bool + }{ + { + name: "Country_in_EEA", + country: "FRA", + eeaList: eeaCountries, + expected: true, + }, + { + name: "Country_in_EEA_lowercase", + country: "fra", + eeaList: eeaCountries, + expected: true, + }, + { + name: "Country_not_in_EEA", + country: "USA", + eeaList: eeaCountries, + expected: false, + }, + { + name: "Empty_country_string", + country: "", + eeaList: eeaCountries, + expected: false, + }, + { + name: "EEA_list_is_empty", + country: "FRA", + eeaList: []string{}, + expected: false, + }, + { + name: "EEA_list_is_nil", + country: "FRA", + eeaList: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isEEACountry(tt.country, tt.eeaList) + assert.Equal(t, tt.expected, result) + }) + } +} + type mockRequestValidator struct { errors []error } diff --git a/exchange/exchangetest/gdpr-geo-eu-on-with-account-eea-countries.json b/exchange/exchangetest/gdpr-geo-eu-on-with-account-eea-countries.json new file mode 100644 index 00000000000..a9b469fb11a --- /dev/null +++ b/exchange/exchangetest/gdpr-geo-eu-on-with-account-eea-countries.json @@ -0,0 +1,75 @@ +{ + "assume_gdpr_applies": true, + "account_eea_countries": ["POL"], + "gdpr_enabled": true, + "incomingRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "prebid": { + "bidder": { + "appnexus": { + "placementId": 1 + } + } + } + } + } + ], + "user": { + "buyeruid": "some-buyer-id", + "geo": { + "country": "POL" + } + } + } + }, + "outgoingRequests": { + "appnexus": { + "expectRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "bidder": { + "placementId": 1 + } + } + } + ], + "user": { + "geo": { + "country": "POL" + } + } + } + }, + "mockResponse": { + "errors": [ + "appnexus-error" + ] + } + } + } +} \ No newline at end of file diff --git a/exchange/gdpr.go b/exchange/gdpr.go index 866f33baed2..8c58a77b468 100644 --- a/exchange/gdpr.go +++ b/exchange/gdpr.go @@ -40,3 +40,12 @@ func enforceGDPR(signal gdpr.Signal, defaultValue gdpr.Signal, channelEnabled bo gdprApplies := signal == gdpr.SignalYes || (signal == gdpr.SignalAmbiguous && defaultValue == gdpr.SignalYes) return gdprApplies && channelEnabled } + +// SelectEEACountries selects the EEA countries based on host and account configurations. +// Account-level configuration takes precedence over the host-level configuration. +func selectEEACountries(hostEEACountries []string, accountEEACountries []string) []string { + if accountEEACountries != nil { + return accountEEACountries + } + return hostEEACountries +} diff --git a/exchange/gdpr_test.go b/exchange/gdpr_test.go index b2d175c2ad0..1ef69d498b1 100644 --- a/exchange/gdpr_test.go +++ b/exchange/gdpr_test.go @@ -148,6 +148,65 @@ func TestGetConsent(t *testing.T) { } } +func TestSelectEEACountries(t *testing.T) { + tests := []struct { + description string + hostEEACountries []string + accountEEACountries []string + expected []string + }{ + { + description: "Account_EEA_countries_provided", + hostEEACountries: []string{"UK", "DE"}, + accountEEACountries: []string{"FR", "IT"}, + expected: []string{"FR", "IT"}, + }, + { + description: "Account_is_nil", + hostEEACountries: []string{"UK"}, + accountEEACountries: nil, + expected: []string{"UK"}, + }, + { + description: "Both_nil", + hostEEACountries: nil, + accountEEACountries: nil, + expected: nil, + }, + { + description: "Account_is_empty_slice", + hostEEACountries: []string{"UK"}, + accountEEACountries: []string{}, + expected: []string{}, + }, + { + description: "Host_is_nil", + hostEEACountries: nil, + accountEEACountries: []string{"DE"}, + expected: []string{"DE"}, + }, + { + description: "Host_and_account_both_non-nil", + hostEEACountries: []string{"UK"}, + accountEEACountries: []string{"FR"}, + expected: []string{"FR"}, + }, + { + description: "Host_is_empty_slice,_account_is_nil", + hostEEACountries: []string{}, + accountEEACountries: nil, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + result := selectEEACountries(tt.hostEEACountries, tt.accountEEACountries) + assert.Equal(t, tt.expected, result) + }) + } +} + var upsv1Section mockGPPSection = mockGPPSection{sectionID: 6, value: "1YNY"} var tcf1Section mockGPPSection = mockGPPSection{sectionID: 2, value: "BOS2bx5OS2bx5ABABBAAABoAAAAAFA"}