diff --git a/account/account.go b/account/account.go index 37d36761cca..b40d9a88dc4 100644 --- a/account/account.go +++ b/account/account.go @@ -49,6 +49,11 @@ func GetAccount(ctx context.Context, cfg *config.Configuration, fetcher stored_r Message: fmt.Sprintf("The prebid-server account config for account id \"%s\" is malformed. Please reach out to the prebid server host.", accountID), }} } + if err := config.UnpackDSADefault(account.Privacy.DSA); err != nil { + return nil, []error{&errortypes.MalformedAcct{ + Message: fmt.Sprintf("The prebid-server account config DSA for account id \"%s\" is malformed. Please reach out to the prebid server host.", accountID), + }} + } // Fill in ID if needed, so it can be left out of account definition if len(account.ID) == 0 { diff --git a/account/account_test.go b/account/account_test.go index 7788f7c430d..b4ea64f0287 100644 --- a/account/account_test.go +++ b/account/account_test.go @@ -12,12 +12,20 @@ import ( "github.com/prebid/prebid-server/v2/openrtb_ext" "github.com/prebid/prebid-server/v2/stored_requests" "github.com/prebid/prebid-server/v2/util/iputil" + "github.com/prebid/prebid-server/v2/util/ptrutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +var ( + validDSA = `{\"dsarequired\":1,\"pubrender\":2,\"transparency\":[{\"domain\":\"test.com\"}]}` + invalidDSA = `{\"dsarequired\":\"invalid\",\"pubrender\":2,\"transparency\":[{\"domain\":\"test.com\"}]}` +) + var mockAccountData = map[string]json.RawMessage{ "valid_acct": json.RawMessage(`{"disabled":false}`), + "valid_acct_dsa": json.RawMessage(`{"disabled":false, "privacy": {"dsa": {"default": "` + validDSA + `"}}}`), + "invalid_acct_dsa": json.RawMessage(`{"disabled":false, "privacy": {"dsa": {"default": "` + invalidDSA + `"}}}`), "invalid_acct_ipv6_ipv4": json.RawMessage(`{"disabled":false, "privacy": {"ipv6": {"anon_keep_bits": -32}, "ipv4": {"anon_keep_bits": -16}}}`), "disabled_acct": json.RawMessage(`{"disabled":true}`), "malformed_acct": json.RawMessage(`{"disabled":"invalid type"}`), @@ -36,6 +44,16 @@ func (af mockAccountFetcher) FetchAccount(ctx context.Context, accountDefaultsJS } func TestGetAccount(t *testing.T) { + validDSA := &openrtb_ext.ExtRegsDSA{ + Required: ptrutil.ToPtr[int8](1), + PubRender: ptrutil.ToPtr[int8](2), + Transparency: []openrtb_ext.ExtBidDSATransparency{ + { + Domain: "test.com", + }, + }, + } + unknown := metrics.PublisherUnknown testCases := []struct { accountID string @@ -44,7 +62,8 @@ func TestGetAccount(t *testing.T) { // account_defaults.disabled disabled bool // checkDefaultIP indicates IPv6 and IPv6 should be set to default values - checkDefaultIP bool + wantDefaultIP bool + wantDSA *openrtb_ext.ExtRegsDSA // expected error, or nil if account should be found err error }{ @@ -66,7 +85,13 @@ func TestGetAccount(t *testing.T) { {accountID: "valid_acct", required: false, disabled: true, err: nil}, {accountID: "valid_acct", required: true, disabled: true, err: nil}, - {accountID: "invalid_acct_ipv6_ipv4", required: true, disabled: false, err: nil, checkDefaultIP: true}, + {accountID: "valid_acct_dsa", required: false, disabled: false, wantDSA: validDSA, err: nil}, + {accountID: "valid_acct_dsa", required: true, disabled: false, wantDSA: validDSA, err: nil}, + {accountID: "valid_acct_dsa", required: false, disabled: true, wantDSA: validDSA, err: nil}, + {accountID: "valid_acct_dsa", required: true, disabled: true, wantDSA: validDSA, err: nil}, + + {accountID: "invalid_acct_ipv6_ipv4", required: true, disabled: false, err: nil, wantDefaultIP: true}, + {accountID: "invalid_acct_dsa", required: false, disabled: false, err: &errortypes.MalformedAcct{}}, // pubID given and matches a host account explicitly disabled (Disabled: true on account json) {accountID: "disabled_acct", required: false, disabled: false, err: &errortypes.AccountDisabled{}}, @@ -111,10 +136,13 @@ func TestGetAccount(t *testing.T) { assert.Nil(t, account, "return account must be nil on error") assert.IsType(t, test.err, errors[0], "error is of unexpected type") } - if test.checkDefaultIP { + if test.wantDefaultIP { assert.Equal(t, account.Privacy.IPv6Config.AnonKeepBits, iputil.IPv6DefaultMaskingBitSize, "ipv6 should be set to default value") assert.Equal(t, account.Privacy.IPv4Config.AnonKeepBits, iputil.IPv4DefaultMaskingBitSize, "ipv4 should be set to default value") } + if test.wantDSA != nil { + assert.Equal(t, test.wantDSA, account.Privacy.DSA.DefaultUnpacked) + } }) } } diff --git a/config/account.go b/config/account.go index cd2a38ffb8d..80ce6d41dea 100644 --- a/config/account.go +++ b/config/account.go @@ -335,6 +335,7 @@ func (m AccountModules) ModuleConfig(id string) (json.RawMessage, error) { type AccountPrivacy struct { AllowActivities *AllowActivities `mapstructure:"allowactivities" json:"allowactivities"` + DSA *AccountDSA `mapstructure:"dsa" json:"dsa"` IPv6Config IPv6 `mapstructure:"ipv6" json:"ipv6"` IPv4Config IPv4 `mapstructure:"ipv4" json:"ipv4"` PrivacySandbox PrivacySandbox `mapstructure:"privacysandbox" json:"privacysandbox"` @@ -350,6 +351,13 @@ type CookieDeprecation struct { TTLSec int `mapstructure:"ttl_sec"` } +// AccountDSA represents DSA configuration +type AccountDSA struct { + Default string `mapstructure:"default" json:"default"` + DefaultUnpacked *openrtb_ext.ExtRegsDSA + GDPROnly bool `mapstructure:"gdpr_only" json:"gdpr_only"` +} + type IPv6 struct { AnonKeepBits int `mapstructure:"anon_keep_bits" json:"anon_keep_bits"` } diff --git a/config/config.go b/config/config.go index 3b306ac03d7..86521c4c36c 100644 --- a/config/config.go +++ b/config/config.go @@ -721,6 +721,10 @@ func New(v *viper.Viper, bidderInfos BidderInfos, normalizeBidderName func(strin return nil, err } + if err := UnpackDSADefault(c.AccountDefaults.Privacy.DSA); err != nil { + return nil, fmt.Errorf("invalid default account DSA: %v", err) + } + // Update account defaults and generate base json for patch c.AccountDefaults.CacheTTL = c.CacheURL.DefaultTTLs // comment this out to set explicitly in config @@ -851,6 +855,14 @@ func (cfg *Configuration) MarshalAccountDefaults() error { return err } +// UnpackDSADefault validates the JSON DSA default object string by unmarshaling and maps it to a struct +func UnpackDSADefault(dsa *AccountDSA) error { + if dsa == nil || len(dsa.Default) == 0 { + return nil + } + return jsonutil.Unmarshal([]byte(dsa.Default), &dsa.DefaultUnpacked) +} + // AccountDefaultsJSON returns the precompiled JSON form of account_defaults func (cfg *Configuration) AccountDefaultsJSON() json.RawMessage { return cfg.accountDefaultsJSON @@ -1151,6 +1163,8 @@ func SetupViper(v *viper.Viper, filename string, bidderInfos BidderInfos) { v.SetDefault("account_defaults.privacy.privacysandbox.cookiedeprecation.ttl_sec", 604800) v.SetDefault("account_defaults.events_enabled", false) + v.BindEnv("account_defaults.privacy.dsa.default") + v.BindEnv("account_defaults.privacy.dsa.gdpr_only") v.SetDefault("account_defaults.privacy.ipv6.anon_keep_bits", 56) v.SetDefault("account_defaults.privacy.ipv4.anon_keep_bits", 24) diff --git a/config/config_test.go b/config/config_test.go index 601c3194cb8..4c97f125997 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -11,6 +11,7 @@ import ( "github.com/prebid/go-gdpr/consentconstants" "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/ptrutil" "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) @@ -528,6 +529,9 @@ account_defaults: anon_keep_bits: 50 ipv4: anon_keep_bits: 20 + dsa: + default: "{\"dsarequired\":3,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}" + gdpr_only: true privacysandbox: topicsdomain: "test.com" cookiedeprecation: @@ -662,6 +666,24 @@ func TestFullConfig(t *testing.T) { cmpInts(t, "account_defaults.price_floors.fetch.max_age_sec", 6000, cfg.AccountDefaults.PriceFloors.Fetcher.MaxAge) cmpInts(t, "account_defaults.price_floors.fetch.max_schema_dims", 10, cfg.AccountDefaults.PriceFloors.Fetcher.MaxSchemaDims) + // Assert the DSA was correctly unmarshalled and DefaultUnpacked was built correctly + expectedDSA := AccountDSA{ + Default: "{\"dsarequired\":3,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}", + DefaultUnpacked: &openrtb_ext.ExtRegsDSA{ + Required: ptrutil.ToPtr[int8](3), + PubRender: ptrutil.ToPtr[int8](1), + DataToPub: ptrutil.ToPtr[int8](2), + Transparency: []openrtb_ext.ExtBidDSATransparency{ + { + Domain: "domain.com", + Params: []int{1}, + }, + }, + }, + GDPROnly: true, + } + assert.Equal(t, &expectedDSA, cfg.AccountDefaults.Privacy.DSA) + cmpBools(t, "account_defaults.events.enabled", true, cfg.AccountDefaults.Events.Enabled) cmpInts(t, "account_defaults.privacy.ipv6.anon_keep_bits", 50, cfg.AccountDefaults.Privacy.IPv6Config.AnonKeepBits) @@ -1856,3 +1878,70 @@ func TestTCF2FeatureOneVendorException(t *testing.T) { assert.Equal(t, tt.wantIsVendorException, value, tt.description) } } + +func TestUnpackDSADefault(t *testing.T) { + tests := []struct { + name string + giveDSA *AccountDSA + wantError bool + }{ + { + name: "nil", + giveDSA: nil, + wantError: false, + }, + { + name: "empty", + giveDSA: &AccountDSA{ + Default: "", + }, + wantError: false, + }, + { + name: "empty_json", + giveDSA: &AccountDSA{ + Default: "{}", + }, + wantError: false, + }, + { + name: "well_formed", + giveDSA: &AccountDSA{ + Default: "{\"dsarequired\":3,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}", + }, + wantError: false, + }, + { + name: "well_formed_with_extra_fields", + giveDSA: &AccountDSA{ + Default: "{\"unmappedkey\":\"unmappedvalue\",\"dsarequired\":3,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}", + }, + wantError: false, + }, + { + name: "invalid_type", + giveDSA: &AccountDSA{ + Default: "{\"dsarequired\":\"invalid\",\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}", + }, + wantError: true, + }, + { + name: "invalid_malformed_missing_colon", + giveDSA: &AccountDSA{ + Default: "{\"dsarequired\"3,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"domain.com\",\"dsaparams\":[1]}]}", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := UnpackDSADefault(tt.giveDSA) + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/dsa/writer.go b/dsa/writer.go new file mode 100644 index 00000000000..9cf276de216 --- /dev/null +++ b/dsa/writer.go @@ -0,0 +1,33 @@ +package dsa + +import ( + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/openrtb_ext" +) + +// Writer is used to write the default DSA to the request (req.regs.ext.dsa) +type Writer struct { + Config *config.AccountDSA + GDPRInScope bool +} + +// Write sets the default DSA object on the request at regs.ext.dsa if it is +// defined in the account config and it is not already present on the request +func (dw Writer) Write(req *openrtb_ext.RequestWrapper) error { + if req == nil || getReqDSA(req) != nil { + return nil + } + if dw.Config == nil || dw.Config.DefaultUnpacked == nil { + return nil + } + if dw.Config.GDPROnly && !dw.GDPRInScope { + return nil + } + regExt, err := req.GetRegExt() + if err != nil { + return err + } + clonedDefaultUnpacked := dw.Config.DefaultUnpacked.Clone() + regExt.SetDSA(clonedDefaultUnpacked) + return nil +} diff --git a/dsa/writer_test.go b/dsa/writer_test.go new file mode 100644 index 00000000000..7f5305121d6 --- /dev/null +++ b/dsa/writer_test.go @@ -0,0 +1,189 @@ +package dsa + +import ( + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/ptrutil" + "github.com/stretchr/testify/assert" +) + +func TestWrite(t *testing.T) { + requestDSAJSON := json.RawMessage(`{"dsa":{"dsarequired":2,"pubrender":1,"datatopub":1,"transparency":[{"domain":"example1.com","dsaparams":[1,2,3]}]}}`) + defaultDSAJSON := json.RawMessage(`{"dsa":{"dsarequired":3,"pubrender":2,"datatopub":2,"transparency":[{"domain":"example2.com","dsaparams":[4,5,6]}]}}`) + defaultDSA := &openrtb_ext.ExtRegsDSA{ + DataToPub: ptrutil.ToPtr[int8](2), + Required: ptrutil.ToPtr[int8](3), + PubRender: ptrutil.ToPtr[int8](2), + Transparency: []openrtb_ext.ExtBidDSATransparency{ + { + Domain: "example2.com", + Params: []int{4, 5, 6}, + }, + }, + } + + tests := []struct { + name string + giveConfig *config.AccountDSA + giveGDPR bool + giveRequest *openrtb_ext.RequestWrapper + expectRequest *openrtb_ext.RequestWrapper + }{ + { + name: "request_nil", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + }, + giveRequest: nil, + expectRequest: nil, + }, + { + name: "config_nil", + giveConfig: nil, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: nil, + }, + }, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: nil, + }, + }, + }, + }, + { + name: "config_default_nil", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: nil, + }, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: nil, + }, + }, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: nil, + }, + }, + }, + }, + { + name: "request_dsa_present", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + }, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: requestDSAJSON, + }, + }, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: requestDSAJSON, + }, + }, + }, + }, + { + name: "config_default_present_with_gdpr_only_set_and_gdpr_in_scope", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + GDPROnly: true, + }, + giveGDPR: true, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{}, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: defaultDSAJSON, + }, + }, + }, + }, + { + name: "config_default_present_with_gdpr_only_set_and_gdpr_not_in_scope", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + GDPROnly: true, + }, + giveGDPR: false, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{}, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{}, + }, + }, + { + name: "config_default_present_with_gdpr_only_not_set_and_gdpr_in_scope", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + GDPROnly: false, + }, + giveGDPR: true, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{}, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: defaultDSAJSON, + }, + }, + }, + }, + { + name: "config_default_present_with_gdpr_only_not_set_and_gdpr_not_in_scope", + giveConfig: &config.AccountDSA{ + DefaultUnpacked: defaultDSA, + GDPROnly: false, + }, + giveGDPR: false, + giveRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{}, + }, + expectRequest: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{ + Ext: defaultDSAJSON, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := Writer{ + Config: tt.giveConfig, + GDPRInScope: tt.giveGDPR, + } + err := writer.Write(tt.giveRequest) + + if tt.giveRequest != nil { + tt.giveRequest.RebuildRequest() + assert.Equal(t, tt.expectRequest.BidRequest, tt.giveRequest.BidRequest) + } else { + assert.Nil(t, tt.giveRequest) + } + assert.Nil(t, err) + }) + } +} diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go index 78bc167b770..87c3936f8b2 100644 --- a/exchange/exchange_test.go +++ b/exchange/exchange_test.go @@ -978,6 +978,7 @@ func TestFloorsSignalling(t *testing.T) { Account: config.Account{DebugAllow: true, PriceFloors: config.AccountPriceFloors{Enabled: test.floorsEnable, MaxRule: 100, MaxSchemaDims: 5}}, UserSyncs: &emptyUsersync{}, HookExecutor: &hookexecution.EmptyHookExecutor{}, + TCF2Config: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}), } outBidResponse, err := e.HoldAuction(context.Background(), auctionRequest, &DebugLog{}) @@ -2170,7 +2171,13 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) { impExtInfoMap[impID] = ImpExtInfo{} } - activityControl := privacy.NewActivityControl(spec.AccountPrivacy) + if spec.AccountPrivacy.DSA != nil && len(spec.AccountPrivacy.DSA.Default) > 0 { + if err := jsonutil.Unmarshal([]byte(spec.AccountPrivacy.DSA.Default), &spec.AccountPrivacy.DSA.DefaultUnpacked); err != nil { + t.Errorf("%s: Exchange returned an unexpected error. Got %s", filename, err.Error()) + } + } + + activityControl := privacy.NewActivityControl(&spec.AccountPrivacy) auctionRequest := &AuctionRequest{ BidRequestWrapper: &openrtb_ext.RequestWrapper{BidRequest: &spec.IncomingRequest.OrtbRequest}, @@ -2181,6 +2188,7 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) { }, DebugAllow: true, PriceFloors: config.AccountPriceFloors{Enabled: spec.AccountFloorsEnabled}, + Privacy: spec.AccountPrivacy, Validations: spec.AccountConfigBidValidation, }, UserSyncs: mockIdFetcher(spec.IncomingRequest.Usersyncs), @@ -5488,7 +5496,7 @@ type exchangeSpec struct { FledgeEnabled bool `json:"fledge_enabled,omitempty"` MultiBid *multiBidSpec `json:"multiBid,omitempty"` Server exchangeServer `json:"server,omitempty"` - AccountPrivacy *config.AccountPrivacy `json:"accountPrivacy,omitempty"` + AccountPrivacy config.AccountPrivacy `json:"accountPrivacy,omitempty"` } type multiBidSpec struct { diff --git a/exchange/exchangetest/dsa-default-ignored.json b/exchange/exchangetest/dsa-default-ignored.json new file mode 100644 index 00000000000..d70009e5c17 --- /dev/null +++ b/exchange/exchangetest/dsa-default-ignored.json @@ -0,0 +1,147 @@ +{ + "accountPrivacy": { + "dsa": { + "default": "{\"dsarequired\":2,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"platform1domain.com\",\"dsaparams\":[1]},{\"domain\":\"SSP2domain.com\",\"dsaparams\":[1,2]}]}" + } + }, + "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 + } + } + } + } + }], + "regs": { + "ext": { + "dsa": { + "dsarequired": 1, + "pubrender": 0, + "datatopub": 2, + "transparency": [{ + "domain": "platform2domain.com", + "dsaparams": [1, 2, 3] + }] + } + } + } + } + }, + "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 + } + } + }], + "regs": { + "ext": { + "dsa": { + "dsarequired": 1, + "pubrender": 0, + "datatopub": 2, + "transparency": [{ + "domain": "platform2domain.com", + "dsaparams": [1, 2, 3] + }] + } + } + } + } + }, + "mockResponse": { + "pbsSeatBids": [{ + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "someField": "someValue", + "origbidcpm": 0.3, + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": [{ + "domain": "dsp1domain.com", + "dsaparams": [1, 2] + }], + "adrender": 1 + } + } + }, + "bidType": "video" + }], + "seat": "appnexus" + }] + } + } + }, + "response": { + "bids": { + "id": "some-request-id", + "seatbid": [{ + "seat": "appnexus", + "bid": [{ + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "someField": "someValue", + "origbidcpm": 0.3, + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "type": "video" + }, + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": [{ + "domain": "dsp1domain.com", + "dsaparams": [1, 2] + }], + "adrender": 1 + } + } + }] + }] + } + } +} \ No newline at end of file diff --git a/exchange/exchangetest/dsa-default.json b/exchange/exchangetest/dsa-default.json new file mode 100644 index 00000000000..efac14415bb --- /dev/null +++ b/exchange/exchangetest/dsa-default.json @@ -0,0 +1,206 @@ +{ + "accountPrivacy": { + "dsa": { + "default": "{\"dsarequired\":2,\"pubrender\":1,\"datatopub\":2,\"transparency\":[{\"domain\":\"platform1domain.com\",\"dsaparams\":[1]},{\"domain\":\"SSP2domain.com\",\"dsaparams\":[1,2]}]}" + } + }, + "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 + }, + "audienceNetwork": { + "placementId": "some-placement" + } + } + } + } + }] + } + }, + "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 + } + } + }], + "regs": { + "ext": { + "dsa": { + "dsarequired": 2, + "pubrender": 1, + "datatopub": 2, + "transparency": [{ + "domain": "platform1domain.com", + "dsaparams": [1] + }, + { + "domain": "SSP2domain.com", + "dsaparams": [1, 2] + } + ] + } + } + } + } + }, + "mockResponse": { + "pbsSeatBids": [{ + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "someField": "someValue", + "origbidcpm": 0.3, + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": [{ + "domain": "dsp1domain.com", + "dsaparams": [1, 2] + }], + "adrender": 1 + } + } + }, + "bidType": "video" + }], + "seat": "appnexus" + }] + } + }, + "audienceNetwork": { + "expectRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "bidder": { + "placementId": "some-placement" + } + } + }], + "regs": { + "ext": { + "dsa": { + "dsarequired": 2, + "pubrender": 1, + "datatopub": 2, + "transparency": [{ + "domain": "platform1domain.com", + "dsaparams": [1] + }, + { + "domain": "SSP2domain.com", + "dsaparams": [1, 2] + } + ] + } + } + } + } + }, + "mockResponse": { + "pbsSeatBids": [{ + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "someField": "someValue", + "origbidcpm": 0.3 + } + }, + "bidType": "video" + }], + "seat": "audienceNetwork" + }] + } + } + }, + "response": { + "bids": { + "id": "some-request-id", + "seatbid": [{ + "seat": "appnexus", + "bid": [{ + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "someField": "someValue", + "origbidcpm": 0.3, + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "type": "video" + }, + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": [{ + "domain": "dsp1domain.com", + "dsaparams": [1, 2] + }], + "adrender": 1 + } + } + }] + }, { + "seat": "audienceNetwork", + "bid": [] + }] + } + } +} \ No newline at end of file diff --git a/exchange/gdpr.go b/exchange/gdpr.go index 52fb860f5df..2f94eefdaef 100644 --- a/exchange/gdpr.go +++ b/exchange/gdpr.go @@ -35,3 +35,9 @@ func getConsent(req *openrtb_ext.RequestWrapper, gpp gpplib.GppContainer) (conse } return *ue.GetConsent(), nil } + +// enforceGDPR determines if GDPR should be enforced based on the request signal and whether the channel is enabled +func enforceGDPR(signal gdpr.Signal, defaultValue gdpr.Signal, channelEnabled bool) bool { + gdprApplies := signal == gdpr.SignalYes || (signal == gdpr.SignalAmbiguous && defaultValue == gdpr.SignalYes) + return gdprApplies && channelEnabled +} diff --git a/exchange/utils.go b/exchange/utils.go index 676c015ae0e..671e2ff86b1 100644 --- a/exchange/utils.go +++ b/exchange/utils.go @@ -18,6 +18,7 @@ import ( "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/dsa" "github.com/prebid/prebid-server/v2/errortypes" "github.com/prebid/prebid-server/v2/firstpartydata" "github.com/prebid/prebid-server/v2/gdpr" @@ -82,8 +83,30 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context, return } + gdprSignal, err := getGDPR(req) + if err != nil { + errs = append(errs, err) + } + channelEnabled := auctionReq.TCF2Config.ChannelEnabled(channelTypeMap[auctionReq.LegacyLabels.RType]) + gdprEnforced := enforceGDPR(gdprSignal, gdprDefaultValue, channelEnabled) + dsaWriter := dsa.Writer{ + Config: auctionReq.Account.Privacy.DSA, + GDPRInScope: gdprEnforced, + } + if err := dsaWriter.Write(req); err != nil { + errs = append(errs, err) + } + if err := req.RebuildRequest(); err != nil { + errs = append(errs, err) + return + } + var allBidderRequests []BidderRequest - allBidderRequests, errs = getAuctionBidderRequests(auctionReq, requestExt, rs.bidderToSyncerKey, impsByBidder, aliases, rs.hostSChainNode) + var allBidderRequestErrs []error + allBidderRequests, allBidderRequestErrs = getAuctionBidderRequests(auctionReq, requestExt, rs.bidderToSyncerKey, impsByBidder, aliases, rs.hostSChainNode) + if allBidderRequestErrs != nil { + errs = append(errs, allBidderRequestErrs...) + } bidderNameToBidderReq := buildBidResponseRequest(req.BidRequest, bidderImpWithBidResp, aliases, auctionReq.BidderImpReplaceImpID) //this function should be executed after getAuctionBidderRequests @@ -103,16 +126,10 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context, applyBidAdjustmentToFloor(allBidderRequests, bidAdjustmentFactors) } - gdprSignal, err := getGDPR(req) - if err != nil { - errs = append(errs, err) - } - consent, err := getConsent(req, gpp) if err != nil { errs = append(errs, err) } - gdprApplies := gdprSignal == gdpr.SignalYes || (gdprSignal == gdpr.SignalAmbiguous && gdprDefaultValue == gdpr.SignalYes) ccpaEnforcer, err := extractCCPA(req.BidRequest, rs.privacyConfig, &auctionReq.Account, aliases, channelTypeMap[auctionReq.LegacyLabels.RType], gpp) if err != nil { @@ -130,13 +147,8 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context, privacyLabels.COPPAEnforced = coppa privacyLabels.LMTEnforced = lmt - var gdprEnforced bool var gdprPerms gdpr.Permissions = &gdpr.AlwaysAllow{} - if gdprApplies { - gdprEnforced = auctionReq.TCF2Config.ChannelEnabled(channelTypeMap[auctionReq.LegacyLabels.RType]) - } - if gdprEnforced { privacyLabels.GDPREnforced = true parsedConsent, err := vendorconsent.ParseString(consent) @@ -227,7 +239,10 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context, privacy.ScrubTID(reqWrapper) } - reqWrapper.RebuildRequest() + err := reqWrapper.RebuildRequest() + if err != nil { + errs = append(errs, err) + } bidderRequest.BidRequest = reqWrapper.BidRequest allowedBidderRequests = append(allowedBidderRequests, bidderRequest) diff --git a/exchange/utils_test.go b/exchange/utils_test.go index 2354e17b2f6..3b723d4e5b9 100644 --- a/exchange/utils_test.go +++ b/exchange/utils_test.go @@ -3219,6 +3219,7 @@ func TestCleanOpenRTBRequestsBidAdjustment(t *testing.T) { BidRequestWrapper: &openrtb_ext.RequestWrapper{BidRequest: req}, UserSyncs: &emptyUsersync{}, Account: accountConfig, + TCF2Config: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}), } gdprPermissionsBuilder := fakePermissionsBuilder{ permissions: &permissionsMock{ @@ -4666,6 +4667,7 @@ func TestCleanOpenRTBRequestsActivities(t *testing.T) { AnonKeepBits: 16, }, }}, + TCF2Config: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}), } bidderToSyncerKey := map[string]string{} diff --git a/openrtb_ext/regs.go b/openrtb_ext/regs.go index eca5ff98e55..7277bb86392 100644 --- a/openrtb_ext/regs.go +++ b/openrtb_ext/regs.go @@ -1,5 +1,9 @@ package openrtb_ext +import ( + "github.com/prebid/prebid-server/v2/util/sliceutil" +) + // ExtRegs defines the contract for bidrequest.regs.ext type ExtRegs struct { // DSA is an object containing DSA transparency information, see https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/dsa_transparency.md @@ -15,8 +19,45 @@ type ExtRegs struct { // ExtRegsDSA defines the contract for bidrequest.regs.ext.dsa type ExtRegsDSA struct { - // Required should be a between 0 and 3 inclusive, see https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/dsa_transparency.md - Required *int8 `json:"dsarequired,omitempty"` - // PubRender should be between 0 and 2 inclusive, see https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/dsa_transparency.md - PubRender *int8 `json:"pubrender,omitempty"` + Required *int8 `json:"dsarequired,omitempty"` + PubRender *int8 `json:"pubrender,omitempty"` + DataToPub *int8 `json:"datatopub,omitempty"` + Transparency []ExtBidDSATransparency `json:"transparency,omitempty"` +} + +// Clone creates a deep copy of ExtRegsDSA +func (erd *ExtRegsDSA) Clone() *ExtRegsDSA { + if erd == nil { + return nil + } + clone := *erd + + if erd.Required != nil { + clonedRequired := *erd.Required + clone.Required = &clonedRequired + } + if erd.PubRender != nil { + clonedPubRender := *erd.PubRender + clone.PubRender = &clonedPubRender + } + if erd.DataToPub != nil { + clonedDataToPub := *erd.DataToPub + clone.DataToPub = &clonedDataToPub + } + if erd.Transparency != nil { + clonedTransparency := make([]ExtBidDSATransparency, len(erd.Transparency)) + for i, transparency := range erd.Transparency { + newTransparency := transparency + newTransparency.Params = sliceutil.Clone(transparency.Params) + clonedTransparency[i] = newTransparency + } + clone.Transparency = clonedTransparency + } + return &clone +} + +// ExtBidDSATransparency defines the contract for bidrequest.regs.ext.dsa.transparency +type ExtBidDSATransparency struct { + Domain string `json:"domain,omitempty"` + Params []int `json:"dsaparams,omitempty"` } diff --git a/openrtb_ext/regs_test.go b/openrtb_ext/regs_test.go new file mode 100644 index 00000000000..a32c0362a1c --- /dev/null +++ b/openrtb_ext/regs_test.go @@ -0,0 +1,97 @@ +package openrtb_ext + +import ( + "testing" + + "github.com/prebid/prebid-server/v2/util/ptrutil" + "github.com/stretchr/testify/assert" +) + +func TestExtRegsDSAClone(t *testing.T) { + tests := []struct { + name string + extRegsDSA *ExtRegsDSA + }{ + { + name: "nil", + extRegsDSA: nil, + }, + { + name: "required_not_nil", + extRegsDSA: &ExtRegsDSA{ + Required: ptrutil.ToPtr[int8](1), + }, + }, + { + name: "pubrender_not_nil", + extRegsDSA: &ExtRegsDSA{ + PubRender: ptrutil.ToPtr[int8](1), + }, + }, + { + name: "datatopub_not_nil", + extRegsDSA: &ExtRegsDSA{ + DataToPub: ptrutil.ToPtr[int8](1), + }, + }, + { + name: "transparency_empty", + extRegsDSA: &ExtRegsDSA{ + Transparency: []ExtBidDSATransparency{}, + }, + }, + { + name: "transparency_with_nil_params", + extRegsDSA: &ExtRegsDSA{ + Transparency: []ExtBidDSATransparency{ + { + Domain: "domain1", + Params: nil, + }, + }, + }, + }, + { + name: "transparency_with_params", + extRegsDSA: &ExtRegsDSA{ + Required: ptrutil.ToPtr[int8](1), + PubRender: ptrutil.ToPtr[int8](1), + DataToPub: ptrutil.ToPtr[int8](1), + Transparency: []ExtBidDSATransparency{ + { + Domain: "domain1", + Params: []int{1, 2, 3}, + }, + { + Domain: "domain2", + Params: []int{4, 5, 6}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clone := tt.extRegsDSA.Clone() + if tt.extRegsDSA == nil { + assert.Nil(t, clone) + } else { + assert.Equal(t, tt.extRegsDSA, clone) + + if tt.extRegsDSA.Required != nil { + assert.NotSame(t, tt.extRegsDSA.Required, clone.Required) + } + if tt.extRegsDSA.PubRender != nil { + assert.NotSame(t, tt.extRegsDSA.PubRender, clone.PubRender) + } + if tt.extRegsDSA.DataToPub != nil { + assert.NotSame(t, tt.extRegsDSA.DataToPub, clone.DataToPub) + } + if tt.extRegsDSA.Transparency != nil { + assert.NotSame(t, tt.extRegsDSA.Transparency, clone.Transparency) + } + } + }) + } +}