Repository: prebid/prebid-server
Pull request #3607 (2024-04-03 21:18:27)

EIDs Should Not Require Unique Sources
Implements https://github.com/prebid/prebid-server/issues/3097


Patch:
diff --git a/account/account.go b/account/account.go
index 37d36761..b40d9a88 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/config/account.go b/config/account.go
index cd2a38ff..80ce6d41 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 3b306ac0..86521c4c 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/dsa/writer.go b/dsa/writer.go
new file mode 100644
index 00000000..9cf276de
--- /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/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 8c178ff3..4f014b96 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -1833,6 +1833,7 @@ func (deps *endpointDeps) validateUser(req *openrtb_ext.RequestWrapper, aliases
 	if err != nil {
 		return append(errL, fmt.Errorf("request.user.ext object is not valid: %v", err))
 	}
+
 	// Check if the buyeruids are valid
 	prebid := userExt.GetPrebid()
 	if prebid != nil {
@@ -1849,19 +1850,15 @@ func (deps *endpointDeps) validateUser(req *openrtb_ext.RequestWrapper, aliases
 			}
 		}
 	}
+
 	// Check Universal User ID
 	eids := userExt.GetEid()
 	if eids != nil {
 		eidsValue := *eids
-		uniqueSources := make(map[string]struct{}, len(eidsValue))
 		for eidIndex, eid := range eidsValue {
 			if eid.Source == "" {
 				return append(errL, fmt.Errorf("request.user.ext.eids[%d] missing required field: \"source\"", eidIndex))
 			}
-			if _, ok := uniqueSources[eid.Source]; ok {
-				return append(errL, errors.New("request.user.ext.eids must contain unique sources"))
-			}
-			uniqueSources[eid.Source] = struct{}{}
 
 			if len(eid.UIDs) == 0 {
 				return append(errL, fmt.Errorf("request.user.ext.eids[%d].uids must contain at least one element or be undefined", eidIndex))
diff --git a/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-source-duplicate.json b/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-source-duplicate.json
deleted file mode 100644
index 05cd7f34..00000000
--- a/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-source-duplicate.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
-  "description": "Bid request where more than one request.user.ext.eids array elements share the same source field value",
-  "mockBidRequest": {
-    "id": "anyRequestID",
-    "site": {
-      "page": "prebid.org",
-      "publisher": {
-        "id": "anyPublisher"
-      }
-    },
-    "imp": [{
-      "id": "anyImpID",
-      "ext": {
-        "appnexus": {
-          "placementId": 42
-        }
-      },
-      "banner": {
-        "format": [{
-            "w": 300,
-            "h": 250
-          },
-          {
-            "w": 300,
-            "h": 300
-          }
-        ]
-      }
-    }],
-    "tmax": 1000,
-    "user": {
-      "ext": {
-        "eids": [{
-          "source": "source1",
-          "uids": [{
-            "id": "A"
-          }]
-        }, {
-          "source": "source1",
-          "uids": [{
-            "id": "B"
-          }]
-        }]
-      }
-    }
-  },
-  "expectedReturnCode": 400,
-  "expectedErrorMessage": "Invalid request: request.user.ext.eids must contain unique sources\n"
-}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json
index 9f047613..ab633724 100644
--- a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json
+++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json
@@ -1,56 +1,59 @@
 {
-    "description": "Bid request with empty eids array in user.ext",
-    "mockBidRequest": {
-        "id": "some-request-id",
-        "site": {
-            "page": "prebid.org"
+  "description": "Bid request with empty eids array in user.ext",
+  "mockBidRequest": {
+    "id": "anyRequestID",
+    "site": {
+      "page": "prebid.org",
+      "publisher": {
+        "id": "anyPublisher"
+      }
+    },
+    "imp": [
+      {
+        "id": "anyImpID",
+        "ext": {
+          "appnexus": {
+            "placementId": 42
+          }
         },
-        "imp": [
+        "banner": {
+          "format": [
             {
-                "id": "some-impression-id",
-                "ext": {
-                    "appnexus": {
-                        "placementId": 123456
-                    }
-                },
-                "banner": {
-                    "format": [
-                        {
-                            "w": 300,
-                            "h": 250
-                        },
-                        {
-                            "w": 300,
-                            "h": 600
-                        }
-                    ]
-                }
-            }
-        ],
-        "tmax": 1000,
-        "user": {
-            "ext": {
-                "eids": []
-            }
-        }
-    },
-    "expectedBidResponse": {
-        "id": "some-request-id",
-        "seatbid": [
+              "w": 300,
+              "h": 250
+            },
             {
-                "bid": [
-                    {
-                        "id": "appnexus-bid",
-                        "impid": "some-impression-id",
-                        "price": 0
-                    }
-                ],
-                "seat": "appnexus"
+              "w": 300,
+              "h": 600
             }
+          ]
+        }
+      }
+    ],
+    "tmax": 1000,
+    "user": {
+      "ext": {
+        "eids": []
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "anyRequestID",
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "appnexus-bid",
+            "impid": "anyImpID",
+            "price": 0
+          }
         ],
-        "bidid": "test bid id",
-        "cur": "USD",
-        "nbr": 0
-    },
-    "expectedReturnCode": 200
+        "seat": "appnexus"
+      }
+    ],
+    "bidid": "test bid id",
+    "cur": "USD",
+    "nbr": 0
+  },
+  "expectedReturnCode": 200
 }
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-source-duplicate.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-source-duplicate.json
new file mode 100644
index 00000000..e4ee6c72
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-source-duplicate.json
@@ -0,0 +1,76 @@
+{
+  "description": "Bid request where more than one request.user.ext.eids array elements share the same source field value",
+  "mockBidRequest": {
+    "id": "anyRequestID",
+    "site": {
+      "page": "prebid.org",
+      "publisher": {
+        "id": "anyPublisher"
+      }
+    },
+    "imp": [
+      {
+        "id": "anyImpID",
+        "ext": {
+          "appnexus": {
+            "placementId": 42
+          }
+        },
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            },
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        }
+      }
+    ],
+    "tmax": 1000,
+    "user": {
+      "ext": {
+        "eids": [
+          {
+            "source": "source1",
+            "uids": [
+              {
+                "id": "A"
+              }
+            ]
+          },
+          {
+            "source": "source1",
+            "uids": [
+              {
+                "id": "B"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "anyRequestID",
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "appnexus-bid",
+            "impid": "anyImpID",
+            "price": 0
+          }
+        ],
+        "seat": "appnexus"
+      }
+    ],
+    "bidid": "test bid id",
+    "cur": "USD",
+    "nbr": 0
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/exchange/gdpr.go b/exchange/gdpr.go
index 52fb860f..2f94eefd 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 676c015a..671e2ff8 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/openrtb_ext/regs.go b/openrtb_ext/regs.go
index eca5ff98..7277bb86 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/static/bidder-info/onetag.yaml b/static/bidder-info/onetag.yaml
index bc52faff..00b83526 100644
--- a/static/bidder-info/onetag.yaml
+++ b/static/bidder-info/onetag.yaml
@@ -19,4 +19,7 @@ userSync:
   iframe:
     url: "https://onetag-sys.com/usync/?redir={{.RedirectURL}}&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}"
     userMacro: "${USER_TOKEN}"
-endpointCompression: "GZIP"
\ No newline at end of file
+  redirect:
+    url: "https://onetag-sys.com/usync/?tag=img&redir={{.RedirectURL}}&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}"
+    userMacro: "${USER_TOKEN}"
+endpointCompression: "GZIP"


Test patch:
diff --git a/account/account_test.go b/account/account_test.go
index 7788f7c4..b4ea64f0 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/config_test.go b/config/config_test.go
index 601c3194..4c97f125 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_test.go b/dsa/writer_test.go
new file mode 100644
index 00000000..7f530512
--- /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/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index af6f4a1b..ee82ffdf 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -3769,51 +3769,51 @@ func TestValidateEidPermissions(t *testing.T) {
 	knownAliases := map[string]string{"b": "b"}
 
 	testCases := []struct {
-		description   string
+		name          string
 		request       *openrtb_ext.ExtRequest
 		expectedError error
 	}{
 		{
-			description:   "Valid - Empty ext",
+			name:          "valid-empty-ext",
 			request:       &openrtb_ext.ExtRequest{},
 			expectedError: nil,
 		},
 		{
-			description:   "Valid - Nil ext.prebid.data",
+			name:          "valid-nil-ext.prebid.data",
 			request:       &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{}},
 			expectedError: nil,
 		},
 		{
-			description:   "Valid - Empty ext.prebid.data",
+			name:          "valid-empty-ext.prebid.data",
 			request:       &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{}}},
 			expectedError: nil,
 		},
 		{
-			description:   "Valid - Nil ext.prebid.data.eidpermissions",
+			name:          "valid-nil-ext.prebid.data.eidpermissions",
 			request:       &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: nil}}},
 			expectedError: nil,
 		},
 		{
-			description:   "Valid - None",
+			name:          "valid-none",
 			request:       &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{}}}},
 			expectedError: nil,
 		},
 		{
-			description: "Valid - One",
+			name: "valid-one",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 			}}}},
 			expectedError: nil,
 		},
 		{
-			description: "Valid - One - Case Insensitive",
+			name: "valid-one-case-insensitive",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"A"}},
 			}}}},
 			expectedError: nil,
 		},
 		{
-			description: "Valid - Many",
+			name: "valid-many",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Source: "sourceB", Bidders: []string{"a"}},
@@ -3821,7 +3821,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: nil,
 		},
 		{
-			description: "Invalid - Missing Source",
+			name: "invalid-missing-source",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Bidders: []string{"a"}},
@@ -3829,7 +3829,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing required field: "source"`),
 		},
 		{
-			description: "Invalid - Duplicate Source",
+			name: "invalid-duplicate-source",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Source: "sourceA", Bidders: []string{"a"}},
@@ -3837,7 +3837,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] duplicate entry with field: "source"`),
 		},
 		{
-			description: "Invalid - Missing Bidders - Nil",
+			name: "invalid-missing-bidders-nil",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Source: "sourceB"},
@@ -3845,7 +3845,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing or empty required field: "bidders"`),
 		},
 		{
-			description: "Invalid - Missing Bidders - Empty",
+			name: "invalid-missing-bidders-empty",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Source: "sourceB", Bidders: []string{}},
@@ -3853,7 +3853,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing or empty required field: "bidders"`),
 		},
 		{
-			description: "Invalid - Invalid Bidders",
+			name: "invalid-invalid-bidders",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"a"}},
 				{Source: "sourceB", Bidders: []string{"z"}},
@@ -3861,7 +3861,7 @@ func TestValidateEidPermissions(t *testing.T) {
 			expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] contains unrecognized bidder "z"`),
 		},
 		{
-			description: "Valid - Alias Case Sensitive",
+			name: "invalid-alias-case-sensitive",
 			request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{
 				{Source: "sourceA", Bidders: []string{"B"}},
 			}}}},
@@ -3871,8 +3871,10 @@ func TestValidateEidPermissions(t *testing.T) {
 
 	endpoint := &endpointDeps{bidderMap: knownBidders, normalizeBidderName: fakeNormalizeBidderName}
 	for _, test := range testCases {
-		result := endpoint.validateEidPermissions(test.request.Prebid.Data, knownAliases)
-		assert.Equal(t, test.expectedError, result, test.description)
+		t.Run(test.name, func(t *testing.T) {
+			result := endpoint.validateEidPermissions(test.request.Prebid.Data, knownAliases)
+			assert.Equal(t, test.expectedError, result)
+		})
 	}
 }
 
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 78bc167b..87c3936f 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 00000000..d70009e5
--- /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 00000000..efac1441
--- /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/utils_test.go b/exchange/utils_test.go
index 2354e17b..3b723d4e 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_test.go b/openrtb_ext/regs_test.go
new file mode 100644
index 00000000..a32c0362
--- /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)
+				}
+			}
+		})
+	}
+}