Repository: prebid/prebid-server
Pull request #2496 (2022-12-16 16:45:30)

Update to support reading GDPR and US Privacy from GPP string


Patch:
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index da56d736..fe642265 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -10,12 +10,14 @@ import (
 	"net/url"
 	"regexp"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/buger/jsonparser"
 	"github.com/gofrs/uuid"
 	"github.com/golang/glog"
 	"github.com/julienschmidt/httprouter"
+	gpplib "github.com/prebid/go-gpp"
 	"github.com/prebid/openrtb/v17/adcom1"
 	"github.com/prebid/openrtb/v17/native1"
 	nativeRequests "github.com/prebid/openrtb/v17/native1/request"
@@ -59,6 +61,16 @@ var (
 	notAmp      int8   = 0
 )
 
+var accountIdSearchPath = [...]struct {
+	isApp bool
+	key   []string
+}{
+	{true, []string{"app", "publisher", "ext", openrtb_ext.PrebidExtKey, "parentAccount"}},
+	{true, []string{"app", "publisher", "id"}},
+	{false, []string{"site", "publisher", "ext", openrtb_ext.PrebidExtKey, "parentAccount"}},
+	{false, []string{"site", "publisher", "id"}},
+}
+
 func NewEndpoint(
 	uuidGenerator uuidutil.UUIDGenerator,
 	ex exchange.Exchange,
@@ -159,7 +171,7 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 
 	w.Header().Set("X-Prebid", version.BuildXPrebidHeader(version.Ver))
 
-	req, impExtInfoMap, storedAuctionResponses, storedBidResponses, bidderImpReplaceImp, errL := deps.parseRequest(r)
+	req, impExtInfoMap, storedAuctionResponses, storedBidResponses, bidderImpReplaceImp, account, errL := deps.parseRequest(r, &labels)
 	if errortypes.ContainsFatalError(errL) && writeError(errL, w, &labels) {
 		return
 	}
@@ -179,26 +191,12 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 	}
 
 	usersyncs := usersync.ParseCookieFromRequest(r, &(deps.cfg.HostCookie))
-	if req.App != nil {
-		labels.Source = metrics.DemandApp
-		labels.RType = metrics.ReqTypeORTB2App
-		labels.PubID = getAccountID(req.App.Publisher)
-	} else { //req.Site != nil
-		labels.Source = metrics.DemandWeb
+	if req.Site != nil {
 		if usersyncs.HasAnyLiveSyncs() {
 			labels.CookieFlag = metrics.CookieFlagYes
 		} else {
 			labels.CookieFlag = metrics.CookieFlagNo
 		}
-		labels.PubID = getAccountID(req.Site.Publisher)
-	}
-
-	// Look up account now that we have resolved the pubID value
-	account, acctIDErrs := accountService.GetAccount(ctx, deps.cfg, deps.accounts, labels.PubID)
-	if len(acctIDErrs) > 0 {
-		errL = append(errL, acctIDErrs...)
-		writeError(errL, w, &labels)
-		return
 	}
 
 	// Set Integration Information
@@ -304,7 +302,7 @@ func sendAuctionResponse(
 // possible, it will return errors with messages that suggest improvements.
 //
 // If the errors list has at least one element, then no guarantees are made about the returned request.
-func (deps *endpointDeps) parseRequest(httpRequest *http.Request) (req *openrtb_ext.RequestWrapper, impExtInfoMap map[string]exchange.ImpExtInfo, storedAuctionResponses stored_responses.ImpsWithBidResponses, storedBidResponses stored_responses.ImpBidderStoredResp, bidderImpReplaceImpId stored_responses.BidderImpReplaceImpID, errs []error) {
+func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metrics.Labels) (req *openrtb_ext.RequestWrapper, impExtInfoMap map[string]exchange.ImpExtInfo, storedAuctionResponses stored_responses.ImpsWithBidResponses, storedBidResponses stored_responses.ImpBidderStoredResp, bidderImpReplaceImpId stored_responses.BidderImpReplaceImpID, account *config.Account, errs []error) {
 	req = &openrtb_ext.RequestWrapper{}
 	req.BidRequest = &openrtb2.BidRequest{}
 	errs = nil
@@ -342,18 +340,65 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request) (req *openrtb_
 
 	impInfo, errs := parseImpInfo(requestJson)
 	if len(errs) > 0 {
-		return nil, nil, nil, nil, nil, errs
+		return nil, nil, nil, nil, nil, nil, errs
+	}
+
+	storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs := deps.getStoredRequests(ctx, requestJson, impInfo)
+	if len(errs) > 0 {
+		return
+	}
+
+	accountId, isAppReq, errs := getAccountIdFromRawRequest(hasStoredBidRequest, storedRequests[storedBidRequestId], requestJson)
+	// fill labels here in order to pass correct metrics in case of errors
+	if isAppReq {
+		labels.Source = metrics.DemandApp
+		labels.RType = metrics.ReqTypeORTB2App
+		labels.PubID = accountId
+	} else { // is Site request
+		labels.Source = metrics.DemandWeb
+		labels.PubID = accountId
+	}
+	if errs != nil {
+		return
+	}
+
+	// Look up account
+	account, errs = accountService.GetAccount(ctx, deps.cfg, deps.accounts, accountId)
+	if len(errs) > 0 {
+		return
+	}
+
+	deps.hookExecutor.SetAccount(account)
+	requestJson, rejectErr = deps.hookExecutor.ExecuteRawAuctionStage(requestJson)
+	if rejectErr != nil {
+		errs = []error{rejectErr}
+		if err = json.Unmarshal(requestJson, req.BidRequest); err != nil {
+			glog.Errorf("Failed to unmarshal BidRequest during raw auction stage rejection: %s", err)
+		}
+		return
+	}
+
+	// retrieve storedRequests and storedImps once more in case stored data was changed by the raw auction hook
+	if hasPayloadUpdatesAt(hooks.StageRawAuctionRequest.String(), deps.hookExecutor.GetOutcomes()) {
+		impInfo, errs = parseImpInfo(requestJson)
+		if len(errs) > 0 {
+			return nil, nil, nil, nil, nil, nil, errs
+		}
+		storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs = deps.getStoredRequests(ctx, requestJson, impInfo)
+		if len(errs) > 0 {
+			return
+		}
 	}
 
 	// Fetch the Stored Request data and merge it into the HTTP request.
-	if requestJson, impExtInfoMap, errs = deps.processStoredRequests(ctx, requestJson, impInfo); len(errs) > 0 {
+	if requestJson, impExtInfoMap, errs = deps.processStoredRequests(requestJson, impInfo, storedRequests, storedImps, storedBidRequestId, hasStoredBidRequest); len(errs) > 0 {
 		return
 	}
 
 	//Stored auction responses should be processed after stored requests due to possible impression modification
 	storedAuctionResponses, storedBidResponses, bidderImpReplaceImpId, errs = stored_responses.ProcessStoredResponses(ctx, requestJson, deps.storedRespFetcher, deps.bidderMap)
 	if len(errs) > 0 {
-		return nil, nil, nil, nil, nil, errs
+		return nil, nil, nil, nil, nil, nil, errs
 	}
 
 	if err := json.Unmarshal(requestJson, req.BidRequest); err != nil {
@@ -388,6 +433,26 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request) (req *openrtb_
 	return
 }
 
+// hasPayloadUpdatesAt checks if there are any successful payload updates at given stage
+func hasPayloadUpdatesAt(stageName string, outcomes []hookexecution.StageOutcome) bool {
+	for _, outcome := range outcomes {
+		if stageName != outcome.Stage {
+			continue
+		}
+
+		for _, group := range outcome.Groups {
+			for _, invocationResult := range group.InvocationResults {
+				if invocationResult.Status == hookexecution.StatusSuccess &&
+					invocationResult.Action == hookexecution.ActionUpdate {
+					return true
+				}
+			}
+		}
+	}
+
+	return false
+}
+
 // parseTimeout returns parses tmax from the requestJson, or returns the default if it doesn't exist.
 //
 // requestJson should be the content of the POST body.
@@ -645,7 +710,17 @@ func (deps *endpointDeps) validateRequest(req *openrtb_ext.RequestWrapper, isAmp
 		return append(errL, err)
 	}
 
-	if ccpaPolicy, err := ccpa.ReadFromRequestWrapper(req); err != nil {
+	var gpp gpplib.GppContainer
+	if req.BidRequest.Regs != nil && len(req.BidRequest.Regs.GPP) > 0 {
+		gpp, err = gpplib.Parse(req.BidRequest.Regs.GPP)
+		if err != nil {
+			errL = append(errL, &errortypes.Warning{
+				Message:     fmt.Sprintf("GPP consent string is invalid and will be ignored. (%v)", err),
+				WarningCode: errortypes.InvalidPrivacyConsentWarningCode})
+		}
+	}
+
+	if ccpaPolicy, err := ccpa.ReadFromRequestWrapper(req, gpp); err != nil {
 		return append(errL, err)
 	} else if _, err := ccpaPolicy.Parse(exchange.GetValidBidders(aliases)); err != nil {
 		if _, invalidConsent := err.(*errortypes.Warning); invalidConsent {
@@ -1681,11 +1756,11 @@ func getJsonSyntaxError(testJSON []byte) (bool, string) {
 	return false, ""
 }
 
-func (deps *endpointDeps) processStoredRequests(ctx context.Context, requestJson []byte, impInfo []ImpExtPrebidData) ([]byte, map[string]exchange.ImpExtInfo, []error) {
+func (deps *endpointDeps) getStoredRequests(ctx context.Context, requestJson []byte, impInfo []ImpExtPrebidData) (string, bool, map[string]json.RawMessage, map[string]json.RawMessage, []error) {
 	// Parse the Stored Request IDs from the BidRequest and Imps.
 	storedBidRequestId, hasStoredBidRequest, err := getStoredRequestId(requestJson)
 	if err != nil {
-		return nil, nil, []error{err}
+		return "", false, nil, nil, []error{err}
 	}
 
 	// Fetch the Stored Request data
@@ -1707,10 +1782,14 @@ func (deps *endpointDeps) processStoredRequests(ctx context.Context, requestJson
 	}
 
 	storedRequests, storedImps, errs := deps.storedReqFetcher.FetchRequests(ctx, storedReqIds, impStoredReqIds)
-
 	if len(errs) != 0 {
-		return nil, nil, errs
+		return "", false, nil, nil, errs
 	}
+
+	return storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs
+}
+
+func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []ImpExtPrebidData, storedRequests map[string]json.RawMessage, storedImps map[string]json.RawMessage, storedBidRequestId string, hasStoredBidRequest bool) ([]byte, map[string]exchange.ImpExtInfo, []error) {
 	bidRequestID, err := getBidRequestID(storedRequests[storedBidRequestId])
 	if err != nil {
 		return nil, nil, []error{err}
@@ -1979,6 +2058,59 @@ func getAccountID(pub *openrtb2.Publisher) string {
 	return metrics.PublisherUnknown
 }
 
+func getAccountIdFromRawRequest(hasStoredRequest bool, storedRequest json.RawMessage, originalRequest []byte) (string, bool, []error) {
+	request := originalRequest
+	if hasStoredRequest {
+		request = storedRequest
+	}
+
+	accountId, isAppReq, err := searchAccountId(request)
+	if err != nil {
+		return "", isAppReq, []error{err}
+	}
+
+	// In case the stored request did not have account data we specifically search it in the original request
+	if accountId == "" && hasStoredRequest {
+		accountId, _, err = searchAccountId(originalRequest)
+		if err != nil {
+			return "", isAppReq, []error{err}
+		}
+	}
+
+	if accountId == "" {
+		return metrics.PublisherUnknown, isAppReq, nil
+	}
+
+	return accountId, isAppReq, nil
+}
+
+func searchAccountId(request []byte) (string, bool, error) {
+	for _, path := range accountIdSearchPath {
+		accountId, exists, err := getStringValueFromRequest(request, path.key)
+		if err != nil {
+			return "", path.isApp, err
+		}
+		if exists {
+			return accountId, path.isApp, nil
+		}
+	}
+	return "", false, nil
+}
+
+func getStringValueFromRequest(request []byte, key []string) (string, bool, error) {
+	val, dataType, _, err := jsonparser.Get(request, key...)
+	if dataType == jsonparser.NotExist {
+		return "", false, nil
+	}
+	if err != nil {
+		return "", false, err
+	}
+	if dataType != jsonparser.String {
+		return "", true, fmt.Errorf("%s must be a string", strings.Join(key, "."))
+	}
+	return string(val), true, nil
+}
+
 func storedRequestErrorChecker(requestJson []byte, storedRequests map[string]json.RawMessage, storedBidRequestId string) []error {
 	if hasErr, syntaxErr := getJsonSyntaxError(requestJson); hasErr {
 		return []error{fmt.Errorf("Invalid JSON in Incoming Request: %s", syntaxErr)}
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json
new file mode 100644
index 00000000..9b77ce56
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json
@@ -0,0 +1,43 @@
+{
+  "description": "Simple request",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            },
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "ext": {}
+  },
+  "expectedBidResponse": {
+    "id": "some-request-id",
+    "nbr": 123
+  },
+  "expectedReturnCode": 200
+}
diff --git a/exchange/gdpr.go b/exchange/gdpr.go
index f75c1f6a..43eba486 100644
--- a/exchange/gdpr.go
+++ b/exchange/gdpr.go
@@ -3,6 +3,8 @@ package exchange
 import (
 	"encoding/json"
 
+	gpplib "github.com/prebid/go-gpp"
+	gppConstants "github.com/prebid/go-gpp/constants"
 	"github.com/prebid/openrtb/v17/openrtb2"
 	"github.com/prebid/prebid-server/gdpr"
 )
@@ -11,6 +13,15 @@ import (
 func extractGDPR(bidRequest *openrtb2.BidRequest) (gdpr.Signal, error) {
 	var re regsExt
 	var err error
+
+	if bidRequest.Regs != nil && len(bidRequest.Regs.GPPSID) > 0 {
+		for _, id := range bidRequest.Regs.GPPSID {
+			if id == int8(gppConstants.SectionTCFEU2) {
+				return gdpr.SignalYes, nil
+			}
+		}
+		return gdpr.SignalNo, nil
+	}
 	if bidRequest.Regs != nil && bidRequest.Regs.Ext != nil {
 		err = json.Unmarshal(bidRequest.Regs.Ext, &re)
 	}
@@ -21,7 +32,13 @@ func extractGDPR(bidRequest *openrtb2.BidRequest) (gdpr.Signal, error) {
 }
 
 // ExtractConsent will pull the consent string from an openrtb request
-func extractConsent(bidRequest *openrtb2.BidRequest) (consent string, err error) {
+func extractConsent(bidRequest *openrtb2.BidRequest, gpp gpplib.GppContainer) (consent string, err error) {
+	for i, id := range gpp.SectionTypes {
+		if id == gppConstants.SectionTCFEU2 {
+			consent = gpp.Sections[i].GetValue()
+			return
+		}
+	}
 	var ue userExt
 	if bidRequest.User != nil && bidRequest.User.Ext != nil {
 		err = json.Unmarshal(bidRequest.User.Ext, &ue)
diff --git a/exchange/utils.go b/exchange/utils.go
index cb442b40..962afe40 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/buger/jsonparser"
 	"github.com/prebid/go-gdpr/vendorconsent"
+	gpplib "github.com/prebid/go-gpp"
 	"github.com/prebid/openrtb/v17/openrtb2"
 
 	"github.com/prebid/prebid-server/adapters"
@@ -78,17 +79,26 @@ func cleanOpenRTBRequests(ctx context.Context,
 	//this function should be executed after getAuctionBidderRequests
 	allBidderRequests = mergeBidderRequests(allBidderRequests, bidderNameToBidderReq)
 
+	var gpp gpplib.GppContainer
+	if req.BidRequest.Regs != nil && len(req.BidRequest.Regs.GPP) > 0 {
+		gpp, err = gpplib.Parse(req.BidRequest.Regs.GPP)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+
 	gdprSignal, err := extractGDPR(req.BidRequest)
 	if err != nil {
 		errs = append(errs, err)
 	}
-	consent, err := extractConsent(req.BidRequest)
+
+	consent, err := extractConsent(req.BidRequest, gpp)
 	if err != nil {
 		errs = append(errs, err)
 	}
 	gdprApplies := gdprSignal == gdpr.SignalYes || (gdprSignal == gdpr.SignalAmbiguous && gdprDefaultValue == gdpr.SignalYes)
 
-	ccpaEnforcer, err := extractCCPA(req.BidRequest, privacyConfig, &auctionReq.Account, aliases, channelTypeMap[auctionReq.LegacyLabels.RType])
+	ccpaEnforcer, err := extractCCPA(req.BidRequest, privacyConfig, &auctionReq.Account, aliases, channelTypeMap[auctionReq.LegacyLabels.RType], gpp)
 	if err != nil {
 		errs = append(errs, err)
 	}
@@ -177,9 +187,9 @@ func ccpaEnabled(account *config.Account, privacyConfig config.Privacy, requestT
 	return privacyConfig.CCPA.Enforce
 }
 
-func extractCCPA(orig *openrtb2.BidRequest, privacyConfig config.Privacy, account *config.Account, aliases map[string]string, requestType config.ChannelType) (privacy.PolicyEnforcer, error) {
+func extractCCPA(orig *openrtb2.BidRequest, privacyConfig config.Privacy, account *config.Account, aliases map[string]string, requestType config.ChannelType, gpp gpplib.GppContainer) (privacy.PolicyEnforcer, error) {
 	// Quick extra wrapper until RequestWrapper makes its way into CleanRequests
-	ccpaPolicy, err := ccpa.ReadFromRequestWrapper(&openrtb_ext.RequestWrapper{BidRequest: orig})
+	ccpaPolicy, err := ccpa.ReadFromRequestWrapper(&openrtb_ext.RequestWrapper{BidRequest: orig}, gpp)
 	if err != nil {
 		return privacy.NilPolicyEnforcer{}, err
 	}
diff --git a/go.mod b/go.mod
index 10933a8d..90f87507 100644
--- a/go.mod
+++ b/go.mod
@@ -19,13 +19,14 @@ require (
 	github.com/lib/pq v1.10.4
 	github.com/mitchellh/copystructure v1.2.0
 	github.com/prebid/go-gdpr v1.11.0
+	github.com/prebid/go-gpp v0.1.1
 	github.com/prebid/openrtb/v17 v17.1.0
 	github.com/prometheus/client_golang v1.12.1
 	github.com/prometheus/client_model v0.2.0
 	github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475
 	github.com/rs/cors v1.8.2
 	github.com/spf13/viper v1.12.0
-	github.com/stretchr/testify v1.7.1
+	github.com/stretchr/testify v1.8.1
 	github.com/vrischmann/go-metrics-influxdb v0.1.1
 	github.com/xeipuuv/gojsonschema v1.2.0
 	github.com/yudai/gojsondiff v1.0.0
@@ -59,7 +60,7 @@ require (
 	github.com/spf13/cast v1.5.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/stretchr/objx v0.1.1 // indirect
+	github.com/stretchr/objx v0.5.0 // indirect
 	github.com/subosito/gotenv v1.3.0 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
diff --git a/go.sum b/go.sum
index de0aa561..30f3017b 100644
--- a/go.sum
+++ b/go.sum
@@ -414,6 +414,8 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/prebid/go-gdpr v1.11.0 h1:QbMjscuw3Ul0mDVWeMy5tP0Kii6lmTSSVhV6fm8rY9s=
 github.com/prebid/go-gdpr v1.11.0/go.mod h1:mPZAdkRxn+iuSjaUuJAi9+0SppBOdM1PCzv/55UH3pY=
+github.com/prebid/go-gpp v0.1.1 h1:uTMJ+eHmKWL9WvDuxFT4LDoOeJW1yOsfWITqi49ZuY0=
+github.com/prebid/go-gpp v0.1.1/go.mod h1:b0TLoVln+HXFD9L9xeimxIH3FN8WDKPJ42auslxEkow=
 github.com/prebid/openrtb/v17 v17.1.0 h1:sFdufdVv9zuoDLuo2/I863lSP9QlEqtZZQyDz5OXPhY=
 github.com/prebid/openrtb/v17 v17.1.0/go.mod h1:nMj7j6aTIopCG91Wv3nuzcFTc7YRSOzuzdPxal+FY50=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -480,16 +482,20 @@ github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy
 github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
 github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/subosito/gotenv v1.3.0 h1:mjC+YW8QpAdXibNi+vNWgzmgBH4+5l5dCXv8cNysBLI=
 github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs=
diff --git a/hooks/hookexecution/executor.go b/hooks/hookexecution/executor.go
index 4c8197df..edc2fe96 100644
--- a/hooks/hookexecution/executor.go
+++ b/hooks/hookexecution/executor.go
@@ -28,6 +28,7 @@ const (
 
 type StageExecutor interface {
 	ExecuteEntrypointStage(req *http.Request, body []byte) ([]byte, *RejectError)
+	ExecuteRawAuctionStage(body []byte) ([]byte, *RejectError)
 }
 
 type HookStageExecutor interface {
@@ -77,7 +78,6 @@ func (e *hookExecutor) ExecuteEntrypointStage(req *http.Request, body []byte) ([
 		return body, nil
 	}
 
-	stageName := hooks.StageEntrypoint.String()
 	handler := func(
 		ctx context.Context,
 		moduleCtx hookstage.ModuleInvocationContext,
@@ -87,15 +87,10 @@ func (e *hookExecutor) ExecuteEntrypointStage(req *http.Request, body []byte) ([
 		return hook.HandleEntrypointHook(ctx, moduleCtx, payload)
 	}
 
-	executionCtx := executionContext{
-		endpoint:       e.endpoint,
-		stage:          stageName,
-		accountId:      e.accountID,
-		account:        e.account,
-		moduleContexts: e.moduleContexts,
-	}
-
+	stageName := hooks.StageEntrypoint.String()
+	executionCtx := e.newContext(stageName)
 	payload := hookstage.EntrypointPayload{Request: req, Body: body}
+
 	outcome, payload, contexts, rejectErr := executeStage(executionCtx, plan, payload, handler, e.metricEngine)
 	outcome.Entity = entityHttpRequest
 	outcome.Stage = stageName
@@ -106,6 +101,45 @@ func (e *hookExecutor) ExecuteEntrypointStage(req *http.Request, body []byte) ([
 	return payload.Body, rejectErr
 }
 
+func (e *hookExecutor) ExecuteRawAuctionStage(requestBody []byte) ([]byte, *RejectError) {
+	plan := e.planBuilder.PlanForRawAuctionStage(e.endpoint, e.account)
+	if len(plan) == 0 {
+		return requestBody, nil
+	}
+
+	handler := func(
+		ctx context.Context,
+		moduleCtx hookstage.ModuleInvocationContext,
+		hook hookstage.RawAuctionRequest,
+		payload hookstage.RawAuctionRequestPayload,
+	) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+		return hook.HandleRawAuctionHook(ctx, moduleCtx, payload)
+	}
+
+	stageName := hooks.StageRawAuctionRequest.String()
+	executionCtx := e.newContext(stageName)
+	payload := hookstage.RawAuctionRequestPayload(requestBody)
+
+	outcome, payload, contexts, reject := executeStage(executionCtx, plan, payload, handler, e.metricEngine)
+	outcome.Entity = entityAuctionRequest
+	outcome.Stage = stageName
+
+	e.saveModuleContexts(contexts)
+	e.pushStageOutcome(outcome)
+
+	return payload, reject
+}
+
+func (e *hookExecutor) newContext(stage string) executionContext {
+	return executionContext{
+		account:        e.account,
+		accountId:      e.accountID,
+		endpoint:       e.endpoint,
+		moduleContexts: e.moduleContexts,
+		stage:          stage,
+	}
+}
+
 func (e *hookExecutor) saveModuleContexts(ctxs stageModuleContext) {
 	for _, moduleCtxs := range ctxs.groupCtx {
 		for moduleName, moduleCtx := range moduleCtxs {
@@ -131,3 +165,7 @@ func (executor *EmptyHookExecutor) GetOutcomes() []StageOutcome {
 func (executor *EmptyHookExecutor) ExecuteEntrypointStage(_ *http.Request, body []byte) ([]byte, *RejectError) {
 	return body, nil
 }
+
+func (executor *EmptyHookExecutor) ExecuteRawAuctionStage(body []byte) ([]byte, *RejectError) {
+	return body, nil
+}
diff --git a/privacy/ccpa/policy.go b/privacy/ccpa/policy.go
index 454cef58..6576e2b7 100644
--- a/privacy/ccpa/policy.go
+++ b/privacy/ccpa/policy.go
@@ -3,6 +3,8 @@ package ccpa
 import (
 	"fmt"
 
+	gpplib "github.com/prebid/go-gpp"
+	gppConstants "github.com/prebid/go-gpp/constants"
 	"github.com/prebid/openrtb/v17/openrtb2"
 	"github.com/prebid/prebid-server/openrtb_ext"
 )
@@ -14,21 +16,34 @@ type Policy struct {
 }
 
 // ReadFromRequestWrapper extracts the CCPA regulatory information from an OpenRTB bid request.
-func ReadFromRequestWrapper(req *openrtb_ext.RequestWrapper) (Policy, error) {
+func ReadFromRequestWrapper(req *openrtb_ext.RequestWrapper, gpp gpplib.GppContainer) (Policy, error) {
 	var consent string
 	var noSaleBidders []string
-
-	if req == nil {
-		return Policy{}, nil
+	if req.BidRequest != nil && req.BidRequest.Regs != nil {
+		for _, s := range req.BidRequest.Regs.GPPSID {
+			if s == int8(gppConstants.SectionUSPV1) {
+				for i, id := range gpp.SectionTypes {
+					if id == gppConstants.SectionUSPV1 {
+						consent = gpp.Sections[i].GetValue()
+					}
+				}
+			}
+		}
 	}
 
-	// Read consent from request.regs.ext
-	regsExt, err := req.GetRegExt()
-	if err != nil {
-		return Policy{}, fmt.Errorf("error reading request.regs.ext: %s", err)
-	}
-	if regsExt != nil {
-		consent = regsExt.GetUSPrivacy()
+	if consent == "" {
+		if req == nil {
+			return Policy{}, nil
+		}
+
+		// Read consent from request.regs.ext
+		regsExt, err := req.GetRegExt()
+		if err != nil {
+			return Policy{}, fmt.Errorf("error reading request.regs.ext: %s", err)
+		}
+		if regsExt != nil {
+			consent = regsExt.GetUSPrivacy()
+		}
 	}
 	// Read no sale bidders from request.ext.prebid
 	reqExt, err := req.GetRequestExt()
@@ -44,7 +59,12 @@ func ReadFromRequestWrapper(req *openrtb_ext.RequestWrapper) (Policy, error) {
 }
 
 func ReadFromRequest(req *openrtb2.BidRequest) (Policy, error) {
-	return ReadFromRequestWrapper(&openrtb_ext.RequestWrapper{BidRequest: req})
+	var gpp gpplib.GppContainer
+	if req != nil && req.Regs != nil && len(req.Regs.GPP) > 0 {
+		gpp, _ = gpplib.Parse(req.Regs.GPP)
+	}
+
+	return ReadFromRequestWrapper(&openrtb_ext.RequestWrapper{BidRequest: req}, gpp)
 }
 
 // Write mutates an OpenRTB bid request with the CCPA regulatory information.


Test patch:
diff --git a/endpoints/openrtb2/amp_auction_test.go b/endpoints/openrtb2/amp_auction_test.go
index 6263c3c2..ca01b86a 100644
--- a/endpoints/openrtb2/amp_auction_test.go
+++ b/endpoints/openrtb2/amp_auction_test.go
@@ -21,6 +21,7 @@ import (
 	"github.com/prebid/prebid-server/exchange"
 	"github.com/prebid/prebid-server/hooks"
 	"github.com/prebid/prebid-server/hooks/hookexecution"
+	"github.com/prebid/prebid-server/hooks/hookstage"
 	metricsConfig "github.com/prebid/prebid-server/metrics/config"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
@@ -1934,67 +1935,65 @@ func TestSetTargeting(t *testing.T) {
 	}
 }
 
-func TestValidAmpResponseWhenRequestRejected(t *testing.T) {
-	reject := hookexecution.RejectError{
-		123,
-		hookexecution.HookID{ModuleCode: "foobar", HookImplCode: "foo"},
-		hooks.StageEntrypoint.String(),
+func TestValidAmpResponseWhenRequestStagesRejected(t *testing.T) {
+	const nbr int = 123
+	const file string = "sample-requests/amp/valid-supplementary/aliased-buyeruids.json"
+
+	var test testCase
+	fileData, err := os.ReadFile(file)
+	assert.NoError(t, err, "Failed to read test file.")
+	if err := json.Unmarshal(fileData, &test); err != nil {
+		t.Fatal("Failed to unmarshal test file.")
 	}
 
 	testCases := []struct {
 		description         string
-		expectedAmpResponse AmpResponse
+		file                string
+		planBuilder         hooks.ExecutionPlanBuilder
+		expectedBidResponse openrtb2.BidResponse
 		hookExecutor        hookexecution.HookStageExecutor
+		expectedAmpResponse AmpResponse
 	}{
 		{
-			"Assert correct AmpResponse when request rejected at entrypoint stage",
-			AmpResponse{Targeting: map[string]string{}},
-			rejectableHookExecutor{entrypointReject: &reject},
+			description:         "Assert correct BidResponse when request rejected at entrypoint stage",
+			file:                "sample-requests/amp/valid-supplementary/aliased-buyeruids.json",
+			planBuilder:         mockPlanBuilder{entrypointPlan: makeRejectPlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
+			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}},
+		},
+		{
+			description:         "Assert correct BidResponse when request rejected at raw-auction stage",
+			file:                "sample-requests/amp/valid-supplementary/aliased-buyeruids.json",
+			planBuilder:         mockPlanBuilder{rawAuctionPlan: makeRejectPlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
+			expectedAmpResponse: test.ExpectedAmpResponse,
 		},
 	}
 
-	for _, test := range testCases {
-		t.Run(test.description, func(t *testing.T) {
-			actualAmpObject := analytics.AmpObject{}
-			logger := newMockLogger(&actualAmpObject, nil)
-			stored := map[string]json.RawMessage{
-				"1": json.RawMessage(validRequest(t, "site.json")),
-			}
+	for _, tc := range testCases {
+		t.Run(tc.description, func(t *testing.T) {
+			request := httptest.NewRequest("GET", fmt.Sprintf("/openrtb2/auction/amp?%s", test.Query), nil)
+			recorder := httptest.NewRecorder()
+			query := request.URL.Query()
+			tagID := query.Get("tag_id")
 
-			deps := &endpointDeps{
-				fakeUUIDGenerator{},
-				&nobidExchange{},
-				mockBidderParamValidator{},
-				&mockAmpStoredReqFetcher{data: stored},
-				empty_fetcher.EmptyFetcher{},
-				empty_fetcher.EmptyFetcher{},
-				&config.Configuration{MaxRequestSize: maxSize},
-				&metricsConfig.NilMetricsEngine{},
-				logger,
-				map[string]string{},
-				false,
-				[]byte{},
-				openrtb_ext.BuildBidderMap(),
-				nil,
-				nil,
-				hardcodedResponseIPValidator{response: true},
-				empty_fetcher.EmptyFetcher{},
-				test.hookExecutor,
-			}
+			test.storedRequest = map[string]json.RawMessage{tagID: test.BidRequest}
+			test.planBuilder = tc.planBuilder
+			test.endpointType = AMP_ENDPOINT
 
-			req := httptest.NewRequest("POST", "/openrtb2/amp?tag_id=1", nil)
-			recorder := httptest.NewRecorder()
+			ampEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, &config.Configuration{MaxRequestSize: maxSize})
+			assert.NoError(t, err, "Failed to build test endpoint.")
 
-			deps.AmpAuction(recorder, req, nil)
+			ampEndpointHandler(recorder, request, nil)
 			assert.Equal(t, recorder.Code, http.StatusOK, "Endpoint should return 200 OK.")
 
-			resp := AmpResponse{}
-			respBytes := recorder.Body.Bytes()
-			err := json.Unmarshal(respBytes, &resp)
+			var actualResp AmpResponse
+			assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &actualResp), "Unable to unmarshal actual BidResponse.")
+			assert.Equal(t, tc.expectedAmpResponse, actualResp, "Invalid AMP Response.")
 
-			assert.NoError(t, err, "Unable to unmarshal response.")
-			assert.Equal(t, test.expectedAmpResponse, resp)
-			assert.Contains(t, actualAmpObject.Errors, reject, "Reject error is not logged to analytics.")
+			// Close servers regardless if the test case was run or not
+			for _, mockBidServer := range mockBidServers {
+				mockBidServer.Close()
+			}
+			mockCurrencyRatesServer.Close()
 		})
 	}
 }
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index 2c55ffd0..a03514d1 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -20,11 +20,9 @@ import (
 	"github.com/prebid/openrtb/v17/native1"
 	nativeRequests "github.com/prebid/openrtb/v17/native1/request"
 	"github.com/prebid/openrtb/v17/openrtb2"
-	"github.com/prebid/openrtb/v17/openrtb3"
-	"github.com/prebid/prebid-server/adapters"
-	"github.com/prebid/prebid-server/analytics"
 	"github.com/prebid/prebid-server/hooks"
 	"github.com/prebid/prebid-server/hooks/hookexecution"
+	"github.com/prebid/prebid-server/hooks/hookstage"
 	"github.com/stretchr/testify/assert"
 
 	analyticsConf "github.com/prebid/prebid-server/analytics/config"
@@ -1100,7 +1098,9 @@ func TestStoredRequests(t *testing.T) {
 	for i, requestData := range testStoredRequests {
 		impInfo, errs := parseImpInfo([]byte(requestData))
 		assert.Len(t, errs, 0, "No errors should be returned")
-		newRequest, impExtInfoMap, errList := deps.processStoredRequests(context.Background(), json.RawMessage(requestData), impInfo)
+		storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs := deps.getStoredRequests(context.Background(), json.RawMessage(requestData), impInfo)
+		assert.Len(t, errs, 0, "No errors should be returned")
+		newRequest, impExtInfoMap, errList := deps.processStoredRequests(json.RawMessage(requestData), impInfo, storedRequests, storedImps, storedBidRequestId, hasStoredBidRequest)
 		if len(errList) != 0 {
 			for _, err := range errList {
 				if err != nil {
@@ -1969,7 +1969,9 @@ func TestStoredRequestGenerateUuid(t *testing.T) {
 		deps.cfg.GenerateRequestID = test.givenGenerateRequestID
 		impInfo, errs := parseImpInfo([]byte(test.givenRawData))
 		assert.Empty(t, errs, test.description)
-		newRequest, _, errList := deps.processStoredRequests(context.Background(), json.RawMessage(test.givenRawData), impInfo)
+		storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs := deps.getStoredRequests(context.Background(), json.RawMessage(test.givenRawData), impInfo)
+		assert.Empty(t, errs, test.description)
+		newRequest, _, errList := deps.processStoredRequests(json.RawMessage(test.givenRawData), impInfo, storedRequests, storedImps, storedBidRequestId, hasStoredBidRequest)
 		assert.Empty(t, errList, test.description)
 
 		if err := json.Unmarshal(newRequest, req); err != nil {
@@ -3308,7 +3310,7 @@ func TestParseRequestParseImpInfoError(t *testing.T) {
 
 	req := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(reqBody))
 
-	resReq, impExtInfoMap, _, _, _, errL := deps.parseRequest(req)
+	resReq, impExtInfoMap, _, _, _, _, errL := deps.parseRequest(req, &metrics.Labels{})
 
 	assert.Nil(t, resReq, "Result request should be nil due to incorrect imp")
 	assert.Nil(t, impExtInfoMap, "Impression info map should be nil due to incorrect imp")
@@ -3883,7 +3885,7 @@ func TestParseRequestMergeBidderParams(t *testing.T) {
 
 			req := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(test.givenRequestBody))
 
-			resReq, _, _, _, _, errL := deps.parseRequest(req)
+			resReq, _, _, _, _, _, errL := deps.parseRequest(req, &metrics.Labels{})
 
 			assert.NoError(t, resReq.RebuildRequest())
 
@@ -3986,7 +3988,7 @@ func TestParseRequestStoredResponses(t *testing.T) {
 
 			req := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(test.givenRequestBody))
 
-			_, _, storedResponses, _, _, errL := deps.parseRequest(req)
+			_, _, storedResponses, _, _, _, errL := deps.parseRequest(req, &metrics.Labels{})
 
 			if test.expectedErrorCount == 0 {
 				assert.Equal(t, test.expectedStoredResponses, storedResponses, "stored responses should match")
@@ -4073,7 +4075,7 @@ func TestParseRequestStoredBidResponses(t *testing.T) {
 			}
 
 			req := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(test.givenRequestBody))
-			_, _, _, storedBidResponses, _, errL := deps.parseRequest(req)
+			_, _, _, storedBidResponses, _, _, errL := deps.parseRequest(req, &metrics.Labels{})
 
 			if test.expectedErrorCount == 0 {
 				assert.Equal(t, test.expectedStoredBidResponses, storedBidResponses, "stored responses should match")
@@ -4655,64 +4657,66 @@ func TestValidateStoredResp(t *testing.T) {
 }
 
 func TestValidResponseWhenRequestRejected(t *testing.T) {
-	nbr := openrtb3.NoBidReason(123)
-	reject := hookexecution.RejectError{
-		int(nbr),
-		hookexecution.HookID{ModuleCode: "foobar", HookImplCode: "foo"},
-		hooks.StageEntrypoint.String(),
-	}
+	const nbr int = 123
 
 	testCases := []struct {
 		description         string
+		file                string
+		planBuilder         hooks.ExecutionPlanBuilder
 		expectedBidResponse openrtb2.BidResponse
 		hookExecutor        hookexecution.HookStageExecutor
 	}{
 		{
-			"Assert correct BidResponse when request rejected at entrypoint stage",
-			openrtb2.BidResponse{ID: "some-request-id", NBR: &nbr},
-			rejectableHookExecutor{entrypointReject: &reject},
+			description: "Assert correct BidResponse when request rejected at entrypoint stage",
+			file:        "sample-requests/valid-whole/hooks/auction_reject.json",
+			planBuilder: mockPlanBuilder{entrypointPlan: makeRejectPlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
+		},
+		{
+			description: "Assert correct BidResponse when request rejected at raw-auction stage",
+			file:        "sample-requests/valid-whole/hooks/auction_reject.json",
+			planBuilder: mockPlanBuilder{rawAuctionPlan: makeRejectPlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
 		},
 	}
 
-	for _, test := range testCases {
-		t.Run(test.description, func(t *testing.T) {
-			actualAuctionObject := analytics.AuctionObject{}
-			logger := newMockLogger(nil, &actualAuctionObject)
-			deps := &endpointDeps{
-				fakeUUIDGenerator{},
-				&nobidExchange{},
-				mockBidderParamValidator{},
-				&mockStoredReqFetcher{},
-				empty_fetcher.EmptyFetcher{},
-				empty_fetcher.EmptyFetcher{},
-				&config.Configuration{MaxRequestSize: maxSize},
-				&metricsConfig.NilMetricsEngine{},
-				logger,
-				map[string]string{},
-				false,
-				[]byte{},
-				openrtb_ext.BuildBidderMap(),
-				nil,
-				nil,
-				hardcodedResponseIPValidator{response: true},
-				empty_fetcher.EmptyFetcher{},
-				test.hookExecutor,
-			}
+	for _, tc := range testCases {
+		t.Run(tc.description, func(t *testing.T) {
+			fileData, err := os.ReadFile(tc.file)
+			assert.NoError(t, err, "Failed to read test file.")
 
-			reqBody := validRequest(t, "../exemplary/simple.json")
-			req := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(reqBody))
-			recorder := httptest.NewRecorder()
+			test, err := parseTestFile(fileData, tc.file)
+			assert.NoError(t, err, "Failed to parse test file.")
+			test.planBuilder = tc.planBuilder
+			test.endpointType = OPENRTB_ENDPOINT
+
+			auctionEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, &config.Configuration{MaxRequestSize: maxSize})
+			assert.NoError(t, err, "Failed to build test endpoint.")
 
-			deps.Auction(recorder, req, nil)
+			recorder := httptest.NewRecorder()
+			req := httptest.NewRequest("POST", "/openrtb2/auction", bytes.NewReader(test.BidRequest))
+			auctionEndpointHandler(recorder, req, nil)
 			assert.Equal(t, recorder.Code, http.StatusOK, "Endpoint should return 200 OK.")
 
-			resp := openrtb2.BidResponse{}
-			respBytes := recorder.Body.Bytes()
-			err := json.Unmarshal(respBytes, &resp)
+			var actualResp openrtb2.BidResponse
+			var expectedResp openrtb2.BidResponse
+			var actualExt openrtb_ext.ExtBidResponse
+			var expectedExt openrtb_ext.ExtBidResponse
 
-			assert.NoError(t, err, "Unable to unmarshal response.")
-			assert.Equal(t, test.expectedBidResponse, resp)
-			assert.Contains(t, actualAuctionObject.Errors, reject, "Reject error is not logged to analytics.")
+			assert.NoError(t, json.Unmarshal(test.ExpectedBidResponse, &expectedResp), "Unable to unmarshal expected BidResponse.")
+			assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &actualResp), "Unable to unmarshal actual BidResponse.")
+			if expectedResp.Ext != nil {
+				assert.NoError(t, json.Unmarshal(expectedResp.Ext, &expectedExt), "Unable to unmarshal expected ExtBidResponse.")
+				assert.NoError(t, json.Unmarshal(actualResp.Ext, &actualExt), "Unable to unmarshal actual ExtBidResponse.")
+			}
+
+			assertBidResponseEqual(t, tc.file, expectedResp, actualResp)
+			assert.Equal(t, expectedResp.NBR, actualResp.NBR, "Invalid NBR.")
+			assert.Equal(t, expectedExt.Warnings, actualExt.Warnings, "Wrong bidResponse.ext.")
+
+			// Close servers regardless if the test case was run or not
+			for _, mockBidServer := range mockBidServers {
+				mockBidServer.Close()
+			}
+			mockCurrencyRatesServer.Close()
 		})
 	}
 }
@@ -4753,40 +4757,3 @@ func getIntegrationFromRequest(req *openrtb_ext.RequestWrapper) (string, error)
 	reqPrebid := reqExt.GetPrebid()
 	return reqPrebid.Integration, nil
 }
-
-type rejectableHookExecutor struct {
-	entrypointReject,
-	rawAuctionReject,
-	processedAuctionReject *hookexecution.RejectError
-}
-
-func (m rejectableHookExecutor) ExecuteEntrypointStage(req *http.Request, body []byte) ([]byte, *hookexecution.RejectError) {
-	return body, m.entrypointReject
-}
-
-func (m rejectableHookExecutor) ExecuteRawAuctionStage(body []byte) ([]byte, *hookexecution.RejectError) {
-	return body, m.rawAuctionReject
-}
-
-func (m rejectableHookExecutor) ExecuteProcessedAuctionStage(req *openrtb2.BidRequest) *hookexecution.RejectError {
-	return m.processedAuctionReject
-}
-
-func (m rejectableHookExecutor) ExecuteBidderRequestStage(req *openrtb2.BidRequest, bidder string) *hookexecution.RejectError {
-	return nil
-}
-
-func (m rejectableHookExecutor) ExecuteRawBidderResponseStage(response *adapters.BidderResponse, bidder string) *hookexecution.RejectError {
-	return nil
-}
-
-func (m rejectableHookExecutor) ExecuteAllProcessedBidResponsesStage(responses []*adapters.BidderResponse) {
-}
-
-func (m rejectableHookExecutor) ExecuteAuctionResponseStage(response *openrtb2.BidResponse) {}
-
-func (m rejectableHookExecutor) SetAccount(account *config.Account) {}
-
-func (m rejectableHookExecutor) GetOutcomes() []hookexecution.StageOutcome {
-	return []hookexecution.StageOutcome{}
-}
diff --git a/endpoints/openrtb2/test_utils.go b/endpoints/openrtb2/test_utils.go
index 90ed6477..ee84005b 100644
--- a/endpoints/openrtb2/test_utils.go
+++ b/endpoints/openrtb2/test_utils.go
@@ -29,6 +29,7 @@ import (
 	"github.com/prebid/prebid-server/experiment/adscert"
 	"github.com/prebid/prebid-server/gdpr"
 	"github.com/prebid/prebid-server/hooks"
+	"github.com/prebid/prebid-server/hooks/hookstage"
 	"github.com/prebid/prebid-server/metrics"
 	metricsConfig "github.com/prebid/prebid-server/metrics/config"
 	"github.com/prebid/prebid-server/openrtb_ext"
@@ -67,6 +68,7 @@ type testCase struct {
 	ExpectedReturnCode      int               `json:"expectedReturnCode,omitempty"`
 	ExpectedErrorMessage    string            `json:"expectedErrorMessage"`
 	Query                   string            `json:"query"`
+	planBuilder             hooks.ExecutionPlanBuilder
 
 	// "/openrtb2/auction" endpoint JSON test info
 	ExpectedBidResponse json.RawMessage `json:"expectedBidResponse"`
@@ -1293,6 +1295,11 @@ func buildTestEndpoint(test testCase, cfg *config.Configuration) (httprouter.Han
 		},
 	}
 
+	planBuilder := test.planBuilder
+	if planBuilder == nil {
+		planBuilder = hooks.EmptyPlanBuilder{}
+	}
+
 	var endpointBuilder func(uuidutil.UUIDGenerator, exchange.Exchange, openrtb_ext.BidderParamValidator, stored_requests.Fetcher, stored_requests.AccountFetcher, *config.Configuration, metrics.MetricsEngine, analytics.PBSAnalyticsModule, map[string]string, []byte, map[string]openrtb_ext.BidderName, stored_requests.Fetcher, hooks.ExecutionPlanBuilder) (httprouter.Handle, error)
 
 	switch test.endpointType {
@@ -1315,7 +1322,7 @@ func buildTestEndpoint(test testCase, cfg *config.Configuration) (httprouter.Han
 		[]byte(test.Config.AliasJSON),
 		bidderMap,
 		storedResponseFetcher,
-		hooks.EmptyPlanBuilder{},
+		planBuilder,
 	)
 
 	return endpoint, testExchange.(*exchangeTestWrapper), mockBidServersArray, mockCurrencyRatesServer, err
@@ -1428,3 +1435,76 @@ func (p *fakePermissions) AuctionActivitiesAllowed(ctx context.Context, bidderCo
 		AllowBidRequest: true,
 	}, nil
 }
+
+type mockPlanBuilder struct {
+	entrypointPlan               hooks.Plan[hookstage.Entrypoint]
+	rawAuctionPlan               hooks.Plan[hookstage.RawAuctionRequest]
+	processedAuctionPlan         hooks.Plan[hookstage.ProcessedAuctionRequest]
+	bidderRequestPlan            hooks.Plan[hookstage.BidderRequest]
+	bidderResponsePlan           hooks.Plan[hookstage.RawBidderResponse]
+	allProcessedBidResponsesPlan hooks.Plan[hookstage.AllProcessedBidResponses]
+	auctionResponsePlan          hooks.Plan[hookstage.AuctionResponse]
+}
+
+func (m mockPlanBuilder) PlanForEntrypointStage(_ string) hooks.Plan[hookstage.Entrypoint] {
+	return m.entrypointPlan
+}
+
+func (m mockPlanBuilder) PlanForRawAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawAuctionRequest] {
+	return m.rawAuctionPlan
+}
+
+func (m mockPlanBuilder) PlanForProcessedAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.ProcessedAuctionRequest] {
+	return m.processedAuctionPlan
+}
+
+func (m mockPlanBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return m.bidderRequestPlan
+}
+
+func (m mockPlanBuilder) PlanForRawBidderResponseStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawBidderResponse] {
+	return m.bidderResponsePlan
+}
+
+func (m mockPlanBuilder) PlanForAllProcessedBidResponsesStage(_ string, _ *config.Account) hooks.Plan[hookstage.AllProcessedBidResponses] {
+	return m.allProcessedBidResponsesPlan
+}
+
+func (m mockPlanBuilder) PlanForAuctionResponseStage(_ string, _ *config.Account) hooks.Plan[hookstage.AuctionResponse] {
+	return m.auctionResponsePlan
+}
+
+func makeRejectPlan[H any](hook H) hooks.Plan[H] {
+	return hooks.Plan[H]{
+		{
+			Timeout: 5 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[H]{
+				{
+					Module: "foobar",
+					Code:   "foo",
+					Hook:   hook,
+				},
+			},
+		},
+	}
+}
+
+type mockRejectionHook struct {
+	nbr int
+}
+
+func (m mockRejectionHook) HandleEntrypointHook(
+	_ context.Context,
+	_ hookstage.ModuleInvocationContext,
+	_ hookstage.EntrypointPayload,
+) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	return hookstage.HookResult[hookstage.EntrypointPayload]{Reject: true, NbrCode: m.nbr}, nil
+}
+
+func (m mockRejectionHook) HandleRawAuctionHook(
+	_ context.Context,
+	_ hookstage.ModuleInvocationContext,
+	_ hookstage.RawAuctionRequestPayload,
+) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{Reject: true, NbrCode: m.nbr}, nil
+}
diff --git a/exchange/gdpr_test.go b/exchange/gdpr_test.go
index 4a17fd8b..382ea04a 100644
--- a/exchange/gdpr_test.go
+++ b/exchange/gdpr_test.go
@@ -4,6 +4,8 @@ import (
 	"encoding/json"
 	"testing"
 
+	gpplib "github.com/prebid/go-gpp"
+	gppConstants "github.com/prebid/go-gpp/constants"
 	"github.com/prebid/openrtb/v17/openrtb2"
 	"github.com/prebid/prebid-server/gdpr"
 	"github.com/stretchr/testify/assert"
@@ -47,21 +49,48 @@ func TestExtractGDPR(t *testing.T) {
 			wantGDPR:    gdpr.SignalAmbiguous,
 			wantError:   true,
 		},
+		{
+			description: "Regs Ext GDPR = null, GPPSID has tcf2",
+			giveRegs:    &openrtb2.Regs{Ext: json.RawMessage(`{"gdpr": null}`), GPPSID: []int8{2}},
+			wantGDPR:    gdpr.SignalYes,
+		},
+		{
+			description: "Regs Ext GDPR = 1, GPPSID has uspv1",
+			giveRegs:    &openrtb2.Regs{Ext: json.RawMessage(`{"gdpr": 1}`), GPPSID: []int8{6}},
+			wantGDPR:    gdpr.SignalNo,
+		},
+		{
+			description: "Regs Ext GDPR = 0, GPPSID has tcf2",
+			giveRegs:    &openrtb2.Regs{Ext: json.RawMessage(`{"gdpr": 0}`), GPPSID: []int8{2}},
+			wantGDPR:    gdpr.SignalYes,
+		},
+		{
+			description: "Regs Ext is nil, GPPSID has tcf2",
+			giveRegs:    &openrtb2.Regs{GPPSID: []int8{2}},
+			wantGDPR:    gdpr.SignalYes,
+		},
+		{
+			description: "Regs Ext is nil, GPPSID has uspv1",
+			giveRegs:    &openrtb2.Regs{GPPSID: []int8{6}},
+			wantGDPR:    gdpr.SignalNo,
+		},
 	}
 
 	for _, tt := range tests {
-		bidReq := openrtb2.BidRequest{
-			Regs: tt.giveRegs,
-		}
+		t.Run(tt.description, func(t *testing.T) {
+			bidReq := openrtb2.BidRequest{
+				Regs: tt.giveRegs,
+			}
 
-		result, err := extractGDPR(&bidReq)
-		assert.Equal(t, tt.wantGDPR, result, tt.description)
+			result, err := extractGDPR(&bidReq)
+			assert.Equal(t, tt.wantGDPR, result)
 
-		if tt.wantError {
-			assert.NotNil(t, err, tt.description)
-		} else {
-			assert.Nil(t, err, tt.description)
-		}
+			if tt.wantError {
+				assert.NotNil(t, err)
+			} else {
+				assert.Nil(t, err)
+			}
+		})
 	}
 }
 
@@ -69,6 +98,7 @@ func TestExtractConsent(t *testing.T) {
 	tests := []struct {
 		description string
 		giveUser    *openrtb2.User
+		giveGPP     gpplib.GppContainer
 		wantConsent string
 		wantError   bool
 	}{
@@ -98,20 +128,56 @@ func TestExtractConsent(t *testing.T) {
 			wantConsent: "",
 			wantError:   true,
 		},
+		{
+			description: "User Ext is nil, GPP has no GDPR",
+			giveUser:    &openrtb2.User{Ext: nil},
+			giveGPP:     gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{6}, Sections: []gpplib.Section{&upsv1Section}},
+			wantConsent: "",
+		},
+		{
+			description: "User Ext is nil, GPP has GDPR",
+			giveUser:    &openrtb2.User{Ext: nil},
+			giveGPP:     gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{2}, Sections: []gpplib.Section{&tcf1Section}},
+			wantConsent: "BOS2bx5OS2bx5ABABBAAABoAAAAAFA",
+		},
+		{
+			description: "User Ext has GDPR, GPP has GDPR",
+			giveUser:    &openrtb2.User{Ext: json.RawMessage(`{"consent": "BSOMECONSENT"}`)},
+			giveGPP:     gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{2}, Sections: []gpplib.Section{&tcf1Section}},
+			wantConsent: "BOS2bx5OS2bx5ABABBAAABoAAAAAFA",
+		},
 	}
 
 	for _, tt := range tests {
-		bidReq := openrtb2.BidRequest{
-			User: tt.giveUser,
-		}
+		t.Run(tt.description, func(t *testing.T) {
+			bidReq := openrtb2.BidRequest{
+				User: tt.giveUser,
+			}
 
-		result, err := extractConsent(&bidReq)
-		assert.Equal(t, tt.wantConsent, result, tt.description)
+			result, err := extractConsent(&bidReq, tt.giveGPP)
+			assert.Equal(t, tt.wantConsent, result, tt.description)
 
-		if tt.wantError {
-			assert.NotNil(t, err, tt.description)
-		} else {
-			assert.Nil(t, err, tt.description)
-		}
+			if tt.wantError {
+				assert.NotNil(t, err, tt.description)
+			} else {
+				assert.Nil(t, err, tt.description)
+			}
+		})
 	}
 }
+
+var upsv1Section mockGPPSection = mockGPPSection{sectionID: 6, value: "1YNY"}
+var tcf1Section mockGPPSection = mockGPPSection{sectionID: 2, value: "BOS2bx5OS2bx5ABABBAAABoAAAAAFA"}
+
+type mockGPPSection struct {
+	sectionID gppConstants.SectionID
+	value     string
+}
+
+func (ms mockGPPSection) GetID() gppConstants.SectionID {
+	return ms.sectionID
+}
+
+func (ms mockGPPSection) GetValue() string {
+	return ms.value
+}
diff --git a/hooks/hookexecution/executor_test.go b/hooks/hookexecution/executor_test.go
index 149c495a..2d484793 100644
--- a/hooks/hookexecution/executor_test.go
+++ b/hooks/hookexecution/executor_test.go
@@ -2,8 +2,6 @@ package hookexecution
 
 import (
 	"bytes"
-	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -29,14 +27,19 @@ func TestEmptyHookExecutor(t *testing.T) {
 	req, err := http.NewRequest(http.MethodPost, "https://prebid.com/openrtb2/auction", reader)
 	assert.NoError(t, err, "Failed to create http request.")
 
-	newBody, rejectErr := executor.ExecuteEntrypointStage(req, body)
-	outcomes := executor.GetOutcomes()
+	entrypointBody, entrypointRejectErr := executor.ExecuteEntrypointStage(req, body)
+	rawAuctionBody, rawAuctionRejectErr := executor.ExecuteRawAuctionStage(body)
 
+	outcomes := executor.GetOutcomes()
 	assert.Equal(t, EmptyHookExecutor{}, executor, "EmptyHookExecutor shouldn't be changed.")
-	assert.Nil(t, rejectErr, "EmptyHookExecutor shouldn't return reject error.")
-	assert.Equal(t, body, newBody, "EmptyHookExecutor shouldn't change body.")
 	assert.Empty(t, outcomes, "EmptyHookExecutor shouldn't return stage outcomes.")
 
+	assert.Nil(t, entrypointRejectErr, "EmptyHookExecutor shouldn't return reject error at entrypoint stage.")
+	assert.Equal(t, body, entrypointBody, "EmptyHookExecutor shouldn't change body at entrypoint stage.")
+
+	assert.Nil(t, rawAuctionRejectErr, "EmptyHookExecutor shouldn't return reject error at raw-auction stage.")
+	assert.Equal(t, body, rawAuctionBody, "EmptyHookExecutor shouldn't change body at raw-auction stage.")
+
 }
 
 func TestExecuteEntrypointStage(t *testing.T) {
@@ -412,79 +415,342 @@ func TestMetricsAreGatheredDuringHookExecution(t *testing.T) {
 	metricEngine.AssertExpectations(t)
 }
 
-type mockUpdateHeaderEntrypointHook struct{}
-
-func (e mockUpdateHeaderEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
-	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-		payload.Request.Header.Add("foo", "bar")
-		return payload, nil
-	}, hookstage.MutationUpdate, "header", "foo")
-
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
-}
-
-type mockUpdateQueryEntrypointHook struct{}
+func TestExecuteRawAuctionStage(t *testing.T) {
+	const body string = `{"name": "John", "last_name": "Doe"}`
+	const bodyUpdated string = `{"last_name": "Doe", "foo": "bar"}`
+	const urlString string = "https://prebid.com/openrtb2/auction"
 
-func (e mockUpdateQueryEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
-	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-		params := payload.Request.URL.Query()
-		params.Add("foo", "baz")
-		payload.Request.URL.RawQuery = params.Encode()
-		return payload, nil
-	}, hookstage.MutationUpdate, "param", "foo")
+	foobarModuleCtx := &moduleContexts{ctxs: map[string]hookstage.ModuleContext{"foobar": nil}}
+	account := &config.Account{}
 
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
-}
+	testCases := []struct {
+		description            string
+		givenBody              string
+		givenUrl               string
+		givenPlanBuilder       hooks.ExecutionPlanBuilder
+		givenAccount           *config.Account
+		expectedBody           string
+		expectedReject         *RejectError
+		expectedModuleContexts *moduleContexts
+		expectedStageOutcomes  []StageOutcome
+	}{
+		{
+			description:            "Payload not changed if hook execution plan empty",
+			givenBody:              body,
+			givenUrl:               urlString,
+			givenPlanBuilder:       hooks.EmptyPlanBuilder{},
+			givenAccount:           account,
+			expectedBody:           body,
+			expectedReject:         nil,
+			expectedModuleContexts: &moduleContexts{ctxs: map[string]hookstage.ModuleContext{}},
+			expectedStageOutcomes:  []StageOutcome{},
+		},
+		{
+			description:            "Payload changed if hooks return mutations",
+			givenBody:              body,
+			givenUrl:               urlString,
+			givenPlanBuilder:       TestApplyHookMutationsBuilder{},
+			givenAccount:           account,
+			expectedBody:           bodyUpdated,
+			expectedReject:         nil,
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					Entity: entityAuctionRequest,
+					Stage:  hooks.StageRawAuctionRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							InvocationResults: []HookOutcome{
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: []string{
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.foo, mutation type: %s", hookstage.MutationUpdate),
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.name, mutation type: %s", hookstage.MutationDelete),
+									},
+									Errors:   nil,
+									Warnings: nil,
+								},
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "bar"},
+									Status:        StatusExecutionFailure,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      []string{"failed to apply hook mutation: key not found"},
+								},
+							},
+						},
+						{
+							InvocationResults: []HookOutcome{
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "baz"},
+									Status:        StatusFailure,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"hook execution failed: attribute not found"},
+									Warnings:      nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:            "Stage execution can be rejected - and later hooks rejected",
+			givenBody:              body,
+			givenUrl:               urlString,
+			givenPlanBuilder:       TestRejectPlanBuilder{},
+			givenAccount:           nil,
+			expectedBody:           bodyUpdated,
+			expectedReject:         &RejectError{0, HookID{ModuleCode: "foobar", HookImplCode: "bar"}, hooks.StageRawAuctionRequest.String()},
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entityAuctionRequest,
+					Stage:         hooks.StageRawAuctionRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: []string{
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.foo, mutation type: %s", hookstage.MutationUpdate),
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.name, mutation type: %s", hookstage.MutationDelete),
+									},
+									Errors:   nil,
+									Warnings: nil,
+								},
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "baz"},
+									Status:        StatusExecutionFailure,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"unexpected error"},
+									Warnings:      nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "bar"},
+									Status:        StatusSuccess,
+									Action:        ActionReject,
+									Message:       "",
+									DebugMessages: nil,
+									Errors: []string{
+										`Module foobar (hook: bar) rejected request with code 0 at raw_auction_request stage`,
+									},
+									Warnings: nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:            "Stage execution can be timed out",
+			givenBody:              body,
+			givenUrl:               urlString,
+			givenPlanBuilder:       TestWithTimeoutPlanBuilder{},
+			givenAccount:           account,
+			expectedBody:           bodyUpdated,
+			expectedReject:         nil,
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entityAuctionRequest,
+					Stage:         hooks.StageRawAuctionRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: []string{
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.foo, mutation type: %s", hookstage.MutationUpdate),
+										fmt.Sprintf("Hook mutation successfully applied, affected key: body.name, mutation type: %s", hookstage.MutationDelete),
+									},
+									Errors:   nil,
+									Warnings: nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "bar"},
+									Status:        StatusTimeout,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"Hook execution timeout"},
+									Warnings:      nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:      "Modules contexts are preserved and correct",
+			givenBody:        body,
+			givenUrl:         urlString,
+			givenPlanBuilder: TestWithModuleContextsPlanBuilder{},
+			givenAccount:     account,
+			expectedBody:     body,
+			expectedReject:   nil,
+			expectedModuleContexts: &moduleContexts{ctxs: map[string]hookstage.ModuleContext{
+				"module-1": {"raw-auction-ctx-1": "some-ctx-1", "raw-auction-ctx-3": "some-ctx-3"},
+				"module-2": {"raw-auction-ctx-2": "some-ctx-2"},
+			}},
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entityAuctionRequest,
+					Stage:         hooks.StageRawAuctionRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "module-1", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionNone,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      nil,
+								},
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "module-2", HookImplCode: "baz"},
+									Status:        StatusSuccess,
+									Action:        ActionNone,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "module-1", HookImplCode: "bar"},
+									Status:        StatusSuccess,
+									Action:        ActionNone,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
 
-type mockUpdateBodyEntrypointHook struct{}
-
-func (e mockUpdateBodyEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
-	c.AddMutation(
-		func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-			payload.Body = []byte(`{"name": "John", "last_name": "Doe", "foo": "bar"}`)
-			return payload, nil
-		}, hookstage.MutationUpdate, "body", "foo",
-	).AddMutation(
-		func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-			payload.Body = []byte(`{"last_name": "Doe", "foo": "bar"}`)
-			return payload, nil
-		}, hookstage.MutationDelete, "body", "name",
-	)
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			exec := NewHookExecutor(test.givenPlanBuilder, EndpointAuction, &metricsConfig.NilMetricsEngine{})
+			exec.SetAccount(test.givenAccount)
 
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
-}
+			newBody, reject := exec.ExecuteRawAuctionStage([]byte(test.givenBody))
 
-type mockRejectEntrypointHook struct{}
+			assert.Equal(t, test.expectedReject, reject, "Unexpected stage reject.")
+			assert.JSONEq(t, test.expectedBody, string(newBody), "Incorrect request body.")
+			assert.Equal(t, test.expectedModuleContexts, exec.moduleContexts, "Incorrect module contexts")
 
-func (e mockRejectEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	return hookstage.HookResult[hookstage.EntrypointPayload]{Reject: true}, nil
+			stageOutcomes := exec.GetOutcomes()
+			if len(test.expectedStageOutcomes) == 0 {
+				assert.Empty(t, stageOutcomes, "Incorrect stage outcomes.")
+			} else {
+				assertEqualStageOutcomes(t, test.expectedStageOutcomes[0], stageOutcomes[0])
+			}
+		})
+	}
 }
 
-type mockTimeoutEntrypointHook struct{}
-
-func (e mockTimeoutEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	time.Sleep(3 * time.Millisecond)
-	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
-	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-		params := payload.Request.URL.Query()
-		params.Add("bar", "foo")
-		payload.Request.URL.RawQuery = params.Encode()
-		return payload, nil
-	}, hookstage.MutationUpdate, "param", "bar")
-
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
-}
+func TestInterStageContextCommunication(t *testing.T) {
+	body := []byte(`{"foo": "bar"}`)
+	reader := bytes.NewReader(body)
+	exec := NewHookExecutor(TestWithModuleContextsPlanBuilder{}, EndpointAuction, &metricsConfig.NilMetricsEngine{})
+	req, err := http.NewRequest(http.MethodPost, "https://prebid.com/openrtb2/auction", reader)
+	assert.NoError(t, err)
 
-type mockModuleContextHook struct {
-	key, val string
-}
+	// test that context added at the entrypoint stage
+	_, reject := exec.ExecuteEntrypointStage(req, body)
+	assert.Nil(t, reject, "Unexpected reject from entrypoint stage.")
+	assert.Equal(
+		t,
+		&moduleContexts{ctxs: map[string]hookstage.ModuleContext{
+			"module-1": {
+				"entrypoint-ctx-1": "some-ctx-1",
+				"entrypoint-ctx-3": "some-ctx-3",
+			},
+			"module-2": {"entrypoint-ctx-2": "some-ctx-2"},
+		}},
+		exec.moduleContexts,
+		"Wrong module contexts after executing entrypoint hook.",
+	)
 
-func (e mockModuleContextHook) HandleEntrypointHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	miCtx.ModuleContext = map[string]interface{}{e.key: e.val}
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ModuleContext: miCtx.ModuleContext}, nil
+	// test that context added at the raw-auction stage merged with existing module contexts
+	_, reject = exec.ExecuteRawAuctionStage(body)
+	assert.Nil(t, reject, "Unexpected reject from raw-auction stage.")
+	assert.Equal(t, &moduleContexts{ctxs: map[string]hookstage.ModuleContext{
+		"module-1": {
+			"entrypoint-ctx-1":  "some-ctx-1",
+			"entrypoint-ctx-3":  "some-ctx-3",
+			"raw-auction-ctx-1": "some-ctx-1",
+			"raw-auction-ctx-3": "some-ctx-3",
+		},
+		"module-2": {
+			"entrypoint-ctx-2":  "some-ctx-2",
+			"raw-auction-ctx-2": "some-ctx-2",
+		},
+	}}, exec.moduleContexts, "Wrong module contexts after executing raw-auction hook.")
 }
 
 type TestApplyHookMutationsBuilder struct {
@@ -497,15 +763,33 @@ func (e TestApplyHookMutationsBuilder) PlanForEntrypointStage(_ string) hooks.Pl
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				{Module: "foobar", Code: "foo", Hook: mockUpdateHeaderEntrypointHook{}},
-				{Module: "foobar", Code: "foobaz", Hook: mockFailedMutationEntrypointHook{}},
+				{Module: "foobar", Code: "foobaz", Hook: mockFailedMutationHook{}},
 				{Module: "foobar", Code: "bar", Hook: mockUpdateQueryEntrypointHook{}},
 			},
 		},
 		hooks.Group[hookstage.Entrypoint]{
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
-				{Module: "foobar", Code: "baz", Hook: mockUpdateBodyEntrypointHook{}},
-				{Module: "foobar", Code: "foo", Hook: mockFailureEntrypointHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockUpdateBodyHook{}},
+				{Module: "foobar", Code: "foo", Hook: mockFailureHook{}},
+			},
+		},
+	}
+}
+
+func (e TestApplyHookMutationsBuilder) PlanForRawAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawAuctionRequest] {
+	return hooks.Plan[hookstage.RawAuctionRequest]{
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockUpdateBodyHook{}},
+				{Module: "foobar", Code: "bar", Hook: mockFailedMutationHook{}},
+			},
+		},
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "baz", Hook: mockFailureHook{}},
 			},
 		},
 	}
@@ -521,17 +805,17 @@ func (e TestRejectPlanBuilder) PlanForEntrypointStage(_ string) hooks.Plan[hooks
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				{Module: "foobar", Code: "foo", Hook: mockUpdateHeaderEntrypointHook{}},
-				{Module: "foobar", Code: "baz", Hook: mockErrorEntrypointHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockErrorHook{}},
 			},
 		},
 		hooks.Group[hookstage.Entrypoint]{
 			Timeout: 5 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				// reject stage
-				{Module: "foobar", Code: "bar", Hook: mockRejectEntrypointHook{}},
+				{Module: "foobar", Code: "bar", Hook: mockRejectHook{}},
 				// next hook rejected: we use timeout hook to make sure
 				// that it runs longer than previous one, so it won't be executed earlier
-				{Module: "foobar", Code: "baz", Hook: mockTimeoutEntrypointHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockTimeoutHook{}},
 			},
 		},
 		// group of hooks rejected
@@ -539,7 +823,36 @@ func (e TestRejectPlanBuilder) PlanForEntrypointStage(_ string) hooks.Plan[hooks
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				{Module: "foobar", Code: "foo", Hook: mockUpdateHeaderEntrypointHook{}},
-				{Module: "foobar", Code: "baz", Hook: mockErrorEntrypointHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockErrorHook{}},
+			},
+		},
+	}
+}
+
+func (e TestRejectPlanBuilder) PlanForRawAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawAuctionRequest] {
+	return hooks.Plan[hookstage.RawAuctionRequest]{
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockUpdateBodyHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockErrorHook{}},
+			},
+		},
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "bar", Hook: mockRejectHook{}},
+				// next hook rejected: we use timeout hook to make sure
+				// that it runs longer than previous one, so it won't be executed earlier
+				{Module: "foobar", Code: "baz", Hook: mockTimeoutHook{}},
+			},
+		},
+		// group of hooks rejected
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockUpdateBodyHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockErrorHook{}},
 			},
 		},
 	}
@@ -555,13 +868,30 @@ func (e TestWithTimeoutPlanBuilder) PlanForEntrypointStage(_ string) hooks.Plan[
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				{Module: "foobar", Code: "foo", Hook: mockUpdateHeaderEntrypointHook{}},
-				{Module: "foobar", Code: "bar", Hook: mockTimeoutEntrypointHook{}},
+				{Module: "foobar", Code: "bar", Hook: mockTimeoutHook{}},
 			},
 		},
 		hooks.Group[hookstage.Entrypoint]{
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
-				{Module: "foobar", Code: "baz", Hook: mockUpdateBodyEntrypointHook{}},
+				{Module: "foobar", Code: "baz", Hook: mockUpdateBodyHook{}},
+			},
+		},
+	}
+}
+
+func (e TestWithTimeoutPlanBuilder) PlanForRawAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawAuctionRequest] {
+	return hooks.Plan[hookstage.RawAuctionRequest]{
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockUpdateBodyHook{}},
+			},
+		},
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "foobar", Code: "bar", Hook: mockTimeoutHook{}},
 			},
 		},
 	}
@@ -589,27 +919,22 @@ func (e TestWithModuleContextsPlanBuilder) PlanForEntrypointStage(_ string) hook
 	}
 }
 
-type mockFailureEntrypointHook struct{}
-
-func (h mockFailureEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	return hookstage.HookResult[hookstage.EntrypointPayload]{}, FailureError{Message: "attribute not found"}
-}
-
-type mockErrorEntrypointHook struct{}
-
-func (h mockErrorEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	return hookstage.HookResult[hookstage.EntrypointPayload]{}, errors.New("unexpected error")
-}
-
-type mockFailedMutationEntrypointHook struct{}
-
-func (h mockFailedMutationEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
-	changeSet := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
-	changeSet.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
-		return payload, errors.New("key not found")
-	}, hookstage.MutationUpdate, "header", "foo")
-
-	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: changeSet}, nil
+func (e TestWithModuleContextsPlanBuilder) PlanForRawAuctionStage(_ string, _ *config.Account) hooks.Plan[hookstage.RawAuctionRequest] {
+	return hooks.Plan[hookstage.RawAuctionRequest]{
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "module-1", Code: "foo", Hook: mockModuleContextHook{key: "raw-auction-ctx-1", val: "some-ctx-1"}},
+				{Module: "module-2", Code: "baz", Hook: mockModuleContextHook{key: "raw-auction-ctx-2", val: "some-ctx-2"}},
+			},
+		},
+		hooks.Group[hookstage.RawAuctionRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+				{Module: "module-1", Code: "bar", Hook: mockModuleContextHook{key: "raw-auction-ctx-3", val: "some-ctx-3"}},
+			},
+		},
+	}
 }
 
 type TestAllHookResultsBuilder struct {
@@ -622,10 +947,10 @@ func (e TestAllHookResultsBuilder) PlanForEntrypointStage(_ string) hooks.Plan[h
 			Timeout: 1 * time.Millisecond,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
 				{Module: "module-1", Code: "code-1", Hook: mockUpdateHeaderEntrypointHook{}},
-				{Module: "module-1", Code: "code-3", Hook: mockTimeoutEntrypointHook{}},
-				{Module: "module-1", Code: "code-4", Hook: mockFailureEntrypointHook{}},
-				{Module: "module-1", Code: "code-5", Hook: mockErrorEntrypointHook{}},
-				{Module: "module-1", Code: "code-6", Hook: mockFailedMutationEntrypointHook{}},
+				{Module: "module-1", Code: "code-3", Hook: mockTimeoutHook{}},
+				{Module: "module-1", Code: "code-4", Hook: mockFailureHook{}},
+				{Module: "module-1", Code: "code-5", Hook: mockErrorHook{}},
+				{Module: "module-1", Code: "code-6", Hook: mockFailedMutationHook{}},
 				{Module: "module-1", Code: "code-7", Hook: mockModuleContextHook{key: "key", val: "val"}},
 			},
 		},
@@ -634,7 +959,7 @@ func (e TestAllHookResultsBuilder) PlanForEntrypointStage(_ string) hooks.Plan[h
 		hooks.Group[hookstage.Entrypoint]{
 			Timeout: 10 * time.Second,
 			Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
-				{Module: "module-1", Code: "code-2", Hook: mockRejectEntrypointHook{}},
+				{Module: "module-1", Code: "code-2", Hook: mockRejectHook{}},
 			},
 		},
 	}
diff --git a/hooks/hookexecution/mocks_test.go b/hooks/hookexecution/mocks_test.go
new file mode 100644
index 00000000..2d609c53
--- /dev/null
+++ b/hooks/hookexecution/mocks_test.go
@@ -0,0 +1,161 @@
+package hookexecution
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/prebid/prebid-server/hooks/hookstage"
+)
+
+type mockUpdateHeaderEntrypointHook struct{}
+
+func (e mockUpdateHeaderEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+		payload.Request.Header.Add("foo", "bar")
+		return payload, nil
+	}, hookstage.MutationUpdate, "header", "foo")
+
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
+}
+
+type mockUpdateQueryEntrypointHook struct{}
+
+func (e mockUpdateQueryEntrypointHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+		params := payload.Request.URL.Query()
+		params.Add("foo", "baz")
+		payload.Request.URL.RawQuery = params.Encode()
+		return payload, nil
+	}, hookstage.MutationUpdate, "param", "foo")
+
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
+}
+
+type mockUpdateBodyHook struct{}
+
+func (e mockUpdateBodyHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+	c.AddMutation(
+		func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+			payload.Body = []byte(`{"name": "John", "last_name": "Doe", "foo": "bar"}`)
+			return payload, nil
+		}, hookstage.MutationUpdate, "body", "foo",
+	).AddMutation(
+		func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+			payload.Body = []byte(`{"last_name": "Doe", "foo": "bar"}`)
+			return payload, nil
+		}, hookstage.MutationDelete, "body", "name",
+	)
+
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
+}
+
+func (e mockUpdateBodyHook) HandleRawAuctionHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	c := &hookstage.ChangeSet[hookstage.RawAuctionRequestPayload]{}
+	c.AddMutation(
+		func(payload hookstage.RawAuctionRequestPayload) (hookstage.RawAuctionRequestPayload, error) {
+			payload = []byte(`{"name": "John", "last_name": "Doe", "foo": "bar"}`)
+			return payload, nil
+		}, hookstage.MutationUpdate, "body", "foo",
+	).AddMutation(
+		func(payload hookstage.RawAuctionRequestPayload) (hookstage.RawAuctionRequestPayload, error) {
+			payload = []byte(`{"last_name": "Doe", "foo": "bar"}`)
+			return payload, nil
+		}, hookstage.MutationDelete, "body", "name",
+	)
+
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{ChangeSet: c}, nil
+}
+
+type mockRejectHook struct{}
+
+func (e mockRejectHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	return hookstage.HookResult[hookstage.EntrypointPayload]{Reject: true}, nil
+}
+
+func (e mockRejectHook) HandleRawAuctionHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{Reject: true}, nil
+}
+
+type mockTimeoutHook struct{}
+
+func (e mockTimeoutHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	time.Sleep(2 * time.Millisecond)
+	c := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+	c.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+		params := payload.Request.URL.Query()
+		params.Add("bar", "foo")
+		payload.Request.URL.RawQuery = params.Encode()
+		return payload, nil
+	}, hookstage.MutationUpdate, "param", "bar")
+
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: c}, nil
+}
+
+func (e mockTimeoutHook) HandleRawAuctionHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	time.Sleep(2 * time.Millisecond)
+	c := &hookstage.ChangeSet[hookstage.RawAuctionRequestPayload]{}
+	c.AddMutation(func(payload hookstage.RawAuctionRequestPayload) (hookstage.RawAuctionRequestPayload, error) {
+		payload = []byte(`{"last_name": "Doe", "foo": "bar", "address": "A st."}`)
+		return payload, nil
+	}, hookstage.MutationUpdate, "param", "address")
+
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{ChangeSet: c}, nil
+}
+
+type mockModuleContextHook struct {
+	key, val string
+}
+
+func (e mockModuleContextHook) HandleEntrypointHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	miCtx.ModuleContext = map[string]interface{}{e.key: e.val}
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ModuleContext: miCtx.ModuleContext}, nil
+}
+
+func (e mockModuleContextHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	miCtx.ModuleContext = map[string]interface{}{e.key: e.val}
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{ModuleContext: miCtx.ModuleContext}, nil
+}
+
+type mockFailureHook struct{}
+
+func (h mockFailureHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	return hookstage.HookResult[hookstage.EntrypointPayload]{}, FailureError{Message: "attribute not found"}
+}
+
+func (h mockFailureHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{}, FailureError{Message: "attribute not found"}
+}
+
+type mockErrorHook struct{}
+
+func (h mockErrorHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	return hookstage.HookResult[hookstage.EntrypointPayload]{}, errors.New("unexpected error")
+}
+
+func (h mockErrorHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{}, errors.New("unexpected error")
+}
+
+type mockFailedMutationHook struct{}
+
+func (h mockFailedMutationHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	changeSet := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+	changeSet.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+		return payload, errors.New("key not found")
+	}, hookstage.MutationUpdate, "header", "foo")
+
+	return hookstage.HookResult[hookstage.EntrypointPayload]{ChangeSet: changeSet}, nil
+}
+
+func (h mockFailedMutationHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	changeSet := &hookstage.ChangeSet[hookstage.RawAuctionRequestPayload]{}
+	changeSet.AddMutation(func(payload hookstage.RawAuctionRequestPayload) (hookstage.RawAuctionRequestPayload, error) {
+		return payload, errors.New("key not found")
+	}, hookstage.MutationUpdate, "header", "foo")
+
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{ChangeSet: changeSet}, nil
+}
diff --git a/privacy/ccpa/policy_test.go b/privacy/ccpa/policy_test.go
index 4740e778..1e87305e 100644
--- a/privacy/ccpa/policy_test.go
+++ b/privacy/ccpa/policy_test.go
@@ -4,15 +4,18 @@ import (
 	"encoding/json"
 	"testing"
 
+	gpplib "github.com/prebid/go-gpp"
+	gppConstants "github.com/prebid/go-gpp/constants"
 	"github.com/prebid/openrtb/v17/openrtb2"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestReadFromRequest(t *testing.T) {
+func TestReadFromRequestWrapper(t *testing.T) {
 	testCases := []struct {
 		description    string
 		request        *openrtb2.BidRequest
+		giveGPP        gpplib.GppContainer
 		expectedPolicy Policy
 		expectedError  bool
 	}{
@@ -153,13 +156,153 @@ func TestReadFromRequest(t *testing.T) {
 				Consent: "1YYY\"},\"oops\":\"malicious\",\"p\":{\"p\":\"",
 			},
 		},
+		{
+			description: "GPP Success",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~present",
+					GPPSID: []int8{6}},
+				Ext: json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			giveGPP: gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{6}, Sections: []gpplib.Section{&upsv1Section}},
+			expectedPolicy: Policy{
+				Consent:       "present",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "GPP Success, has Regs.ext",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~present",
+					GPPSID: []int8{6},
+					Ext:    json.RawMessage(`{"us_privacy":"ABC"}`)},
+				Ext: json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			giveGPP: gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{6}, Sections: []gpplib.Section{&upsv1Section}},
+			expectedPolicy: Policy{
+				Consent:       "present",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "GPP Success, no USPV1",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA",
+					GPPSID: []int8{6}},
+			},
+			giveGPP: gpplib.GppContainer{Version: 1, SectionTypes: []gppConstants.SectionID{2}, Sections: []gpplib.Section{&tcf1Section}},
+			expectedPolicy: Policy{
+				Consent: "",
+			},
+		},
 	}
 
 	for _, test := range testCases {
-		reqWrapper := &openrtb_ext.RequestWrapper{BidRequest: test.request}
-		result, err := ReadFromRequestWrapper(reqWrapper)
-		assertError(t, test.expectedError, err, test.description)
-		assert.Equal(t, test.expectedPolicy, result, test.description)
+		t.Run(test.description, func(t *testing.T) {
+			reqWrapper := &openrtb_ext.RequestWrapper{BidRequest: test.request}
+			result, err := ReadFromRequestWrapper(reqWrapper, test.giveGPP)
+			assertError(t, test.expectedError, err, test.description)
+			assert.Equal(t, test.expectedPolicy, result)
+		})
+	}
+}
+
+func TestReadFromRequest(t *testing.T) {
+	testCases := []struct {
+		description    string
+		request        *openrtb2.BidRequest
+		expectedPolicy Policy
+		expectedError  bool
+	}{
+		{
+			description: "Success",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{Ext: json.RawMessage(`{"us_privacy":"ABC"}`)},
+				Ext:  json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			expectedPolicy: Policy{
+				Consent:       "ABC",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "Nil Request",
+			request:     nil,
+			expectedPolicy: Policy{
+				Consent:       "",
+				NoSaleBidders: nil,
+			},
+		},
+		{
+			description: "Nil Regs",
+			request: &openrtb2.BidRequest{
+				Regs: nil,
+				Ext:  json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			expectedPolicy: Policy{
+				Consent:       "",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "GPP Success",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN",
+					GPPSID: []int8{6}},
+				Ext: json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			expectedPolicy: Policy{
+				Consent:       "1YNN",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "GPP Success, has Regs.ext",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN",
+					GPPSID: []int8{6},
+					Ext:    json.RawMessage(`{"us_privacy":"ABC"}`)},
+				Ext: json.RawMessage(`{"prebid":{"nosale":["a", "b"]}}`),
+			},
+			expectedPolicy: Policy{
+				Consent:       "1YNN",
+				NoSaleBidders: []string{"a", "b"},
+			},
+		},
+		{
+			description: "GPP Success, no USPV1",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA",
+					GPPSID: []int8{6}}},
+			expectedPolicy: Policy{
+				Consent: "",
+			},
+		},
+		{
+			description: "GPP Success, no signal",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN",
+					GPPSID: []int8{}}},
+			expectedPolicy: Policy{
+				Consent: "",
+			},
+		},
+		{
+			description: "GPP Success, wrong signal",
+			request: &openrtb2.BidRequest{
+				Regs: &openrtb2.Regs{GPP: "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN",
+					GPPSID: []int8{2}}},
+			expectedPolicy: Policy{
+				Consent: "",
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			result, err := ReadFromRequest(test.request)
+			assertError(t, test.expectedError, err, test.description)
+			assert.Equal(t, test.expectedPolicy, result)
+		})
 	}
 }
 
@@ -654,3 +797,19 @@ func assertError(t *testing.T, expectError bool, err error, description string)
 		assert.NoError(t, err, description)
 	}
 }
+
+var upsv1Section mockGPPSection = mockGPPSection{sectionID: 6, value: "present"}
+var tcf1Section mockGPPSection = mockGPPSection{sectionID: 2, value: "BOS2bx5OS2bx5ABABBAAABoAAAAAFA"}
+
+type mockGPPSection struct {
+	sectionID gppConstants.SectionID
+	value     string
+}
+
+func (ms mockGPPSection) GetID() gppConstants.SectionID {
+	return ms.sectionID
+}
+
+func (ms mockGPPSection) GetValue() string {
+	return ms.value
+}