Repository: prebid/prebid-server
Pull request #2907 (2023-07-06 11:42:48)

script to run semgrep tests against adapter PRs
## Description

- PR #2833 introduced two Semgrep rules for adapters i.e  [builder-struct-name.yml](https://github.com/prebid/prebid-server/blob/f1787d4a1da2dc632de7e4afaec9ff71734e146f/.semgrep/adapter/builder-struct-name.yml) and  [type-bid-assignment.yml](https://github.com/prebid/prebid-server/blob/f1787d4a1da2dc632de7e4afaec9ff71734e146f/.semgrep/adapter/type-bid-assignment.yml)
- This PR adds a GitHub action script and some helper to run above Semgrep rules as PR checks. These checks are conditioned to run only  if new PR makes some adapter related changes
- Findings from above semgrep rules will be reported as PR code review comment.  To avoid duplication, previous comments will not be added again
- Script only considers findings that are relevant to the changes made in the PR. For example, if the PR modifies line 10 in the existing file "foo.go" and Semgrep detects issues on lines 5 and 10, only the issue on line 10 will be taken into account. This approach limits the scope of the Semgrep checks to the specific changes made in the PR
- Script will continue to fail until all high severity errors reported by Semgrep are fixed


## Testings
`Tested script on fork repo - https://github.com/onkarvhanumante/prebid-server/pulls`

-  Scenario 1 Adapter PR created
    PR: https://github.com/onkarvhanumante/prebid-server/pull/44
    Expected: Semgrep result get uploaded as comment. Semgrep PR checks will fail
 
-  Scenario 2 New commits pushed to fix some errors previously reported by PR checks
   PR: https://github.com/onkarvhanumante/prebid-server/pull/45
   Expected: No duplicate comments added for unaddressed errors. Since all the errors are not fixed therefore Semgrep PR checks will still continue to fail

- Scenario 3 Commits pushed to fix all errors previously reported by PR checks
   PR: https://github.com/onkarvhanumante/prebid-server/pull/46
   Expected: All errors are addressed. So Semgrep PR checks will get passed

- Scenario 4 PR has no adapter related change
  PR: https://github.com/onkarvhanumante/prebid-server/pull/47
  Expected: Semgrep checks won't be executed 

## Review notes
PR introduces 2 files - `semgrep.yml` and `pull-request-utils.js`. Action script semgrep.yml  makes use of helpers from pull-request-utils.js.

For example, semgrep.yml has above code block.
https://github.com/prebid/prebid-server/blob/d78c3d279ae7fde3762baa74b5ed9eb98f73ac48/.github/workflows/semgrep.yml#L21-L27
Here pull-request-utils.js is imported on line 21. Helper class `diffHelper` is initialised on line 26 and helper method `buildDiff()` is called on line 27. Therefore start reviewing from semgrep.yml file






Patch:
diff --git a/.github/workflows/helpers/pull-request-utils.js b/.github/workflows/helpers/pull-request-utils.js
new file mode 100644
index 00000000..64ea5382
--- /dev/null
+++ b/.github/workflows/helpers/pull-request-utils.js
@@ -0,0 +1,337 @@
+const synchronizeEvent = "synchronize",
+  openedEvent = "opened",
+  completedStatus = "completed",
+  resultSize = 100
+
+class diffHelper {
+  constructor(input) {
+    this.owner = input.context.repo.owner
+    this.repo = input.context.repo.repo
+    this.github = input.github
+    this.pullRequestNumber = input.context.payload.pull_request.number
+    this.pullRequestEvent = input.event
+    this.testName = input.testName
+    this.fileNameFilter = !input.fileNameFilter ? () => true : input.fileNameFilter
+    this.fileLineFilter = !input.fileLineFilter ? () => true : input.fileLineFilter
+  }
+
+  /*
+    Checks whether the test defined by this.testName has been executed on the given commit
+    @param {string} commit - commit SHA to check for test execution
+    @returns {boolean} - returns true if the test has been executed on the commit, otherwise false
+  */
+  async #isTestExecutedOnCommit(commit) {
+    const response = await this.github.rest.checks.listForRef({
+      owner: this.owner,
+      repo: this.repo,
+      ref: commit,
+    })
+
+    return response.data.check_runs.some(
+      ({ status, name }) => status === completedStatus && name === this.testName
+    )
+  }
+
+  /*
+    Retrieves the line numbers of added or updated lines in the provided files
+    @param {Array} files - array of files containing their filename and patch
+    @returns {Object} - object mapping filenames to arrays of line numbers indicating the added or updated lines
+  */
+  async #getDiffForFiles(files = []) {
+    let diff = {}
+    for (const { filename, patch } of files) {
+      if (this.fileNameFilter(filename)) {
+        const lines = patch.split("\n")
+        if (lines.length === 1) {
+          continue
+        }
+
+        let lineNumber
+        for (const line of lines) {
+          // Check if line is diff header
+          //  example:
+          //    @@ -1,3 +1,3 @@
+          //    1    var a
+          //    2
+          //    3   - //test
+          //    3   +var b
+          // Here @@ -1,3 +1,3 @@ is diff header
+          if (line.match(/@@\s.*?@@/) != null) {
+            lineNumber = parseInt(line.match(/\+(\d+)/)[0])
+            continue
+          }
+
+          // "-" prefix indicates line was deleted. So do not consider deleted line
+          if (line.startsWith("-")) {
+            continue
+          }
+
+          // "+"" prefix indicates line was added or updated. Include line number in diff details
+          if (line.startsWith("+") && this.fileLineFilter(line)) {
+            diff[filename] = diff[filename] || []
+            diff[filename].push(lineNumber)
+          }
+          lineNumber++
+        }
+      }
+    }
+    return diff
+  }
+
+  /*
+    Retrieves a list of commits that have not been checked by the test defined by this.testName
+    @returns {Array} - array of commit SHAs that have not been checked by the test
+  */
+  async #getNonScannedCommits() {
+    const { data } = await this.github.rest.pulls.listCommits({
+      owner: this.owner,
+      repo: this.repo,
+      pull_number: this.pullRequestNumber,
+      per_page: resultSize,
+    })
+    let nonScannedCommits = []
+
+    // API returns commits in ascending order. Loop in reverse to quickly retrieve unchecked commits
+    for (let i = data.length - 1; i >= 0; i--) {
+      const { sha, parents } = data[i]
+
+      // Commit can be merged master commit. Such commit have multiple parents
+      // Do not consider such commit for building file diff
+      if (parents.length > 1) {
+        continue
+      }
+
+      const isTestExecuted = await this.#isTestExecutedOnCommit(sha)
+      if (isTestExecuted) {
+        // Remaining commits have been tested in previous scans. Therefore, do not need to be considered again
+        break
+      } else {
+        nonScannedCommits.push(sha)
+      }
+    }
+
+    // Reverse to return commits in ascending order. This is needed to build diff for commits in chronological order
+    return nonScannedCommits.reverse()
+  }
+
+  /*
+    Filters the commit diff to include only the files that are part of the PR diff
+    @param {Array} commitDiff - array of line numbers representing lines added or updated in the commit
+    @param {Array} prDiff - array of line numbers representing lines added or updated in the pull request
+    @returns {Array} - filtered commit diff, including only the files that are part of the PR diff
+  */
+  async #filterCommitDiff(commitDiff = [], prDiff = []) {
+    return commitDiff.filter((file) => prDiff.includes(file))
+  }
+
+  /*
+    Builds the diff for the pull request, including both the changes in the pull request and the changes in non-scanned commits
+    @returns {string} - json string representation of the pull request diff and the diff for non-scanned commits
+  */
+  async buildDiff() {
+    const { data } = await this.github.rest.pulls.listFiles({
+      owner: this.owner,
+      repo: this.repo,
+      pull_number: this.pullRequestNumber,
+      per_page: resultSize,
+    })
+
+    const pullRequestDiff = await this.#getDiffForFiles(data)
+
+    const nonScannedCommitsDiff =
+      Object.keys(pullRequestDiff).length != 0 && this.pullRequestEvent === synchronizeEvent // The "synchronize" event implies that new commit are pushed after the pull request was opened
+        ? await this.getNonScannedCommitDiff(pullRequestDiff)
+        : {}
+
+    const prDiffFiles = Object.keys(pullRequestDiff)
+    const pullRequest = {
+      hasChanges: prDiffFiles.length > 0,
+      files: prDiffFiles.join(" "),
+      diff: pullRequestDiff,
+    }
+    const uncheckedCommits = { diff: nonScannedCommitsDiff }
+    return JSON.stringify({ pullRequest, uncheckedCommits })
+  }
+
+  /*
+    Retrieves the diff for non-scanned commits by comparing their changes with the pull request diff
+    @param {Object} pullRequestDiff - The diff of files in the pull request
+    @returns {Object} - The diff of files in the non-scanned commits that are part of the pull request diff
+   */
+  async getNonScannedCommitDiff(pullRequestDiff) {
+    let nonScannedCommitsDiff = {}
+    // Retrieves list of commits that have not been scanned by the PR check
+    const nonScannedCommits = await this.#getNonScannedCommits()
+    for (const commit of nonScannedCommits) {
+      const { data } = await this.github.rest.repos.getCommit({
+        owner: this.owner,
+        repo: this.repo,
+        ref: commit,
+      })
+
+      const commitDiff = await this.#getDiffForFiles(data.files)
+      const files = Object.keys(commitDiff)
+      for (const file of files) {
+        // Consider scenario where the changes made to a file in the initial commit are completely undone by subsequent commits
+        // In such cases, the modifications from the initial commit should not be taken into account
+        // If the changes were entirely removed, there should be no entry for the file in the pullRequestStats
+        const filePRDiff = pullRequestDiff[file]
+        if (!filePRDiff) {
+          continue
+        }
+
+        // Consider scenario where changes made in the commit were partially removed or modified by subsequent commits
+        // In such cases, include only those commit changes that are part of the pullRequestStats object
+        // This ensures that only the changes that are reflected in the pull request are considered
+        const changes = await this.#filterCommitDiff(commitDiff[file], filePRDiff)
+
+        if (changes.length !== 0) {
+          // Check if nonScannedCommitsDiff[file] exists, if not assign an empty array to it
+          nonScannedCommitsDiff[file] = nonScannedCommitsDiff[file] || []
+          // Combine the existing nonScannedCommitsDiff[file] array with the commit changes
+          // Remove any duplicate elements using the Set data structure
+          nonScannedCommitsDiff[file] = [
+            ...new Set([...nonScannedCommitsDiff[file], ...changes]),
+          ]
+        }
+      }
+    }
+    return nonScannedCommitsDiff
+  }
+}
+
+class semgrepHelper {
+  constructor(input) {
+    this.owner = input.context.repo.owner
+    this.repo = input.context.repo.repo
+    this.github = input.github
+
+    this.pullRequestNumber = input.context.payload.pull_request.number
+    this.pullRequestEvent = input.event
+
+    this.pullRequestDiff = input.diff.pullRequest.diff
+    this.newCommitsDiff = input.diff.uncheckedCommits.diff
+
+    this.semgrepErrors = []
+    this.semgrepWarnings = []
+    input.semgrepResult.forEach((res) => {
+      res.severity === "High" ? this.semgrepErrors.push(res) : this.semgrepWarnings.push(res)
+    })
+
+    this.headSha = input.headSha
+  }
+
+  /*
+    Retrieves the matching line number from the provided diff for a given file and range of lines
+    @param {Object} range - object containing the file, start line, and end line to find a match
+    @param {Object} diff - object containing file changes and corresponding line numbers
+    @returns {number|null} - line number that matches the range within the diff, or null if no match is found
+  */
+  async #getMatchingLineFromDiff({ file, start, end }, diff) {
+    const fileDiff = diff[file]
+    if (!fileDiff) {
+      return null
+    }
+    if (fileDiff.includes(start)) {
+      return start
+    }
+    if (fileDiff.includes(end)) {
+      return end
+    }
+    return null
+  }
+
+  /*
+    Splits the semgrep results into different categories based on the scan
+    @param {Array} semgrepResults - array of results reported by semgrep
+    @returns {Object} - object containing the categorized semgrep results i.e results reported in previous scans and new results found in the current scan
+  */
+  async #splitSemgrepResultsByScan(semgrepResults = []) {
+    const result = {
+      nonDiff: [], // Errors or warnings found in files updated in pull request, but not part of sections that were modified in the pull request
+      previous: [], // Errors or warnings found in previous semgrep scans
+      current: [], // Errors or warnings found in current semgrep scan
+    }
+
+    for (const se of semgrepResults) {
+      const prDiffLine = await this.#getMatchingLineFromDiff(se, this.pullRequestDiff)
+      if (!prDiffLine) {
+        result.nonDiff.push({ ...se })
+        continue
+      }
+
+      switch (this.pullRequestEvent) {
+        case openedEvent:
+          // "Opened" event implies that this is the first check
+          // Therefore, the error should be appended to the result.current
+          result.current.push({ ...se, line: prDiffLine })
+        case synchronizeEvent:
+          const commitDiffLine = await this.#getMatchingLineFromDiff(se, this.newCommitsDiff)
+          // Check if error or warning is part of current commit diff
+          // If not then error or warning was reported in previous scans
+          commitDiffLine != null
+            ? result.current.push({ ...se, line: commitDiffLine })
+            : result.previous.push({
+                ...se,
+                line: prDiffLine,
+              })
+      }
+    }
+    return result
+  }
+
+  /*
+    Adds review comments based on the semgrep results to the current pull request
+    @returns {Object} - object containing the count of unaddressed comments from the previous scan and the count of new comments from the current scan
+  */
+  async addReviewComments() {
+    let result = {
+      previousScan: { unAddressedComments: 0 },
+      currentScan: { newComments: 0 },
+    }
+
+    if (this.semgrepErrors.length == 0 && this.semgrepWarnings.length == 0) {
+      return result
+    }
+
+    const errors = await this.#splitSemgrepResultsByScan(this.semgrepErrors)
+    if (errors.previous.length == 0 && errors.current.length == 0) {
+      console.log("Semgrep did not find any errors in the current pull request changes")
+    } else {
+      for (const { message, file, line } of errors.current) {
+        await this.github.rest.pulls.createReviewComment({
+          owner: this.owner,
+          repo: this.repo,
+          pull_number: this.pullRequestNumber,
+          commit_id: this.headSha,
+          body: message,
+          path: file,
+          line: line,
+        })
+      }
+      result.currentScan.newComments = errors.current.length
+      if (this.pullRequestEvent == synchronizeEvent) {
+        result.previousScan.unAddressedComments = errors.previous.length
+      }
+    }
+
+    const warnings = await this.#splitSemgrepResultsByScan(this.semgrepWarnings)
+    for (const { message, file, line } of warnings.current) {
+      await this.github.rest.pulls.createReviewComment({
+        owner: this.owner,
+        repo: this.repo,
+        pull_number: this.pullRequestNumber,
+        commit_id: this.headSha,
+        body: "Consider this as a suggestion. " + message,
+        path: file,
+        line: line,
+      })
+    }
+    return result
+  }
+}
+
+module.exports = {
+  diffHelper: (input) => new diffHelper(input),
+  semgrepHelper: (input) => new semgrepHelper(input),
+}
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
new file mode 100644
index 00000000..87c5573e
--- /dev/null
+++ b/.github/workflows/semgrep.yml
@@ -0,0 +1,74 @@
+name: Adapter semgrep checks
+on:
+  pull_request:
+    paths: ["adapters/*/*.go"]
+permissions: write-all
+jobs:
+  semgrep-check:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Calculate diff
+        id: calculate_diff
+        uses: actions/github-script@v6
+        with:
+          result-encoding: string
+          script: |
+            const utils = require('./.github/workflows/helpers/pull-request-utils.js')
+            // consider only non-test Go files that are part of the adapter code
+            function fileNameFilter(filename) {
+              return filename.startsWith("adapters/") && filename.split("/").length > 2 && filename.endsWith(".go") && !filename.endsWith("_test.go")
+            }
+            const helper = utils.diffHelper({github, context, fileNameFilter, event: "${{github.event.action}}", testName: "${{github.job}}"})
+            return await helper.buildDiff()
+
+      - name: Should run semgrep
+        id: should_run_semgrep
+        run: |
+          hasChanges=$(echo '${{ steps.calculate_diff.outputs.result }}' | jq .pullRequest.hasChanges)
+          echo "hasChanges=${hasChanges}" >> $GITHUB_OUTPUT
+
+      - name: Install semgrep
+        if: contains(steps.should_run_semgrep.outputs.hasChanges, 'true')
+        run: |
+          pip3 install semgrep==1.22.0
+          semgrep --version
+
+      - name: Run semgrep tests
+        id: run_semgrep_tests
+        if: contains(steps.should_run_semgrep.outputs.hasChanges, 'true')
+        run: |
+          unqouted_string=$(echo '${{ steps.calculate_diff.outputs.result }}' | jq .pullRequest.files | tr -d '"')
+          outputs=$(semgrep --gitlab-sast --config=.semgrep/adapter $unqouted_string  | jq '[.vulnerabilities[] | {"file": .location.file, "severity": .severity, "start": .location.start_line, "end": .location.end_line, "message": (.message | gsub("\\n"; "\n"))}]' | jq -c | jq -R)
+          echo "semgrep_result=${outputs}" >> "$GITHUB_OUTPUT"
+
+      - name: Add pull request comment
+        id: add_pull_request_comment
+        if: contains(steps.should_run_semgrep.outputs.hasChanges, 'true')
+        uses: actions/github-script@v6.4.1
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          result-encoding: string
+          script: |
+            const utils = require('./.github/workflows/helpers/pull-request-utils.js')
+            const helper = utils.semgrepHelper({
+                github, context, event: "${{github.event.action}}", 
+                semgrepResult: JSON.parse(${{ steps.run_semgrep_tests.outputs.semgrep_result }}), 
+                diff: ${{ steps.calculate_diff.outputs.result }}, headSha: "${{github.event.pull_request.head.sha}}"
+            })
+            const { previousScan, currentScan } = await helper.addReviewComments()
+            return previousScan.unAddressedComments + currentScan.newComments
+
+      - name: Adapter semgrep checks result
+        if: contains(steps.should_run_semgrep.outputs.hasChanges, 'true')
+        run: |
+          if [ "${{steps.add_pull_request_comment.outputs.result}}" -ne "0" ]; then
+              echo 'Semgrep has found "${{steps.add_pull_request_comment.outputs.result}}" errors'
+              exit 1
+          else
+              echo 'Semgrep did not find any errors in the pull request changes'
+          fi
diff --git a/.semgrep/adapter/builder-struct-name.yml b/.semgrep/adapter/builder-struct-name.yml
index c80e8e12..bc876ae1 100644
--- a/.semgrep/adapter/builder-struct-name.yml
+++ b/.semgrep/adapter/builder-struct-name.yml
@@ -16,7 +16,7 @@ rules:
           return &adapter{endpoint: "https://www.foo.com"}, nil
         }
       ```
-    severity: WARNING
+    severity: ERROR
     patterns:
       - pattern-either:
           - pattern-inside: >
diff --git a/adapters/ix/ix.go b/adapters/ix/ix.go
index 919f58da..796fefbb 100644
--- a/adapters/ix/ix.go
+++ b/adapters/ix/ix.go
@@ -7,7 +7,6 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/golang/glog"
 	"github.com/prebid/prebid-server/adapters"
 	"github.com/prebid/prebid-server/config"
 	"github.com/prebid/prebid-server/errortypes"
@@ -123,7 +122,6 @@ func setSitePublisherId(requestCopy *openrtb2.BidRequest, uniqueSiteIDs map[stri
 			// Sorting siteIDs for predictable output as Go maps don't guarantee order
 			sort.Strings(siteIDs)
 			multipleSiteIDs := strings.Join(siteIDs, ", ")
-			glog.Warningf("Multiple SiteIDs found. %s", multipleSiteIDs)
 			ixDiag.MultipleSiteIds = multipleSiteIDs
 		}
 		requestCopy.Site = &site
diff --git a/config/account.go b/config/account.go
index 8beff9b6..02040211 100644
--- a/config/account.go
+++ b/config/account.go
@@ -40,7 +40,7 @@ type Account struct {
 	Validations             Validations                                 `mapstructure:"validations" json:"validations"`
 	DefaultBidLimit         int                                         `mapstructure:"default_bid_limit" json:"default_bid_limit"`
 	BidAdjustments          *openrtb_ext.ExtRequestPrebidBidAdjustments `mapstructure:"bidadjustments" json:"bidadjustments"`
-	Privacy                 AccountPrivacy                              `mapstructure:"privacy" json:"privacy"`
+	Privacy                 *AccountPrivacy                             `mapstructure:"privacy" json:"privacy"`
 }
 
 // CookieSync represents the account-level defaults for the cookie sync endpoint.
diff --git a/config/activity.go b/config/activity.go
index 987cbe84..5bddc7c6 100644
--- a/config/activity.go
+++ b/config/activity.go
@@ -8,16 +8,17 @@ type AllowActivities struct {
 	TransmitUserFPD          Activity `mapstructure:"transmitUfpd" json:"transmitUfpd"`
 	TransmitPreciseGeo       Activity `mapstructure:"transmitPreciseGeo" json:"transmitPreciseGeo"`
 	TransmitUniqueRequestIds Activity `mapstructure:"transmitUniqueRequestIds" json:"transmitUniqueRequestIds"`
+	TransmitTids             Activity `mapstructure:"transmitTid" json:"transmitTid"`
 }
 
 type Activity struct {
 	Default *bool          `mapstructure:"default" json:"default"`
 	Rules   []ActivityRule `mapstructure:"rules" json:"rules"`
-	Allow   bool           `mapstructure:"allow" json:"allow"`
 }
 
 type ActivityRule struct {
 	Condition ActivityCondition `mapstructure:"condition" json:"condition"`
+	Allow     bool              `mapstructure:"allow" json:"allow"`
 }
 
 type ActivityCondition struct {
diff --git a/config/config.go b/config/config.go
index ee18c746..543d1afd 100644
--- a/config/config.go
+++ b/config/config.go
@@ -155,6 +155,10 @@ func (cfg *Configuration) validate(v *viper.Viper) []error {
 		cfg.TmaxAdjustments.Enabled = false
 	}
 
+	if cfg.AccountDefaults.Privacy != nil {
+		glog.Warning("account_defaults.Privacy has no effect as the feature is under development.")
+	}
+
 	errs = cfg.Experiment.validate(errs)
 	errs = cfg.BidderInfos.validate(errs)
 	return errs
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index 73f4842a..8d328b7c 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/prebid/prebid-server/privacy"
 	"net/http"
 	"net/url"
 	"strings"
@@ -222,6 +223,13 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 
 	tcf2Config := gdpr.NewTCF2Config(deps.cfg.GDPR.TCF2, account.GDPR)
 
+	activities, activitiesErr := privacy.NewActivityControl(account.Privacy)
+	if activitiesErr != nil {
+		errL = append(errL, activitiesErr)
+		writeError(errL, w, &labels)
+		return
+	}
+
 	secGPC := r.Header.Get("Sec-GPC")
 
 	auctionRequest := &exchange.AuctionRequest{
@@ -239,6 +247,7 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 		HookExecutor:               hookExecutor,
 		QueryParams:                r.URL.Query(),
 		TCF2Config:                 tcf2Config,
+		Activities:                 activities,
 	}
 
 	auctionResponse, err := deps.ex.HoldAuction(ctx, auctionRequest, nil)
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 216bff6e..3f01967a 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/prebid/prebid-server/privacy"
 	"io"
 	"io/ioutil"
 	"net/http"
@@ -191,6 +192,15 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 
 	tcf2Config := gdpr.NewTCF2Config(deps.cfg.GDPR.TCF2, account.GDPR)
 
+	activities, activitiesErr := privacy.NewActivityControl(account.Privacy)
+	if activitiesErr != nil {
+		errL = append(errL, activitiesErr)
+		if errortypes.ContainsFatalError(errL) {
+			writeError(errL, w, &labels)
+			return
+		}
+	}
+
 	ctx := context.Background()
 
 	timeout := deps.cfg.AuctionTimeouts.LimitAuctionTimeout(time.Duration(req.TMax) * time.Millisecond)
@@ -236,6 +246,7 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 		PubID:                      labels.PubID,
 		HookExecutor:               hookExecutor,
 		TCF2Config:                 tcf2Config,
+		Activities:                 activities,
 	}
 	auctionResponse, err := deps.ex.HoldAuction(ctx, auctionRequest, nil)
 	ao.RequestWrapper = req
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 24ca193f..07dce45a 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/prebid/prebid-server/privacy"
 	"math/rand"
 	"net/url"
 	"runtime/debug"
@@ -195,6 +196,7 @@ type AuctionRequest struct {
 	GlobalPrivacyControlHeader string
 	ImpExtInfoMap              map[string]ImpExtInfo
 	TCF2Config                 gdpr.TCF2ConfigReader
+	Activities                 privacy.ActivityControl
 
 	// LegacyLabels is included here for temporary compatibility with cleanOpenRTBRequests
 	// in HoldAuction until we get to factoring it away. Do not use for anything new.
diff --git a/exchange/utils.go b/exchange/utils.go
index d43a4182..5b29fd62 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -150,6 +150,15 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context,
 	for _, bidderRequest := range allBidderRequests {
 		bidRequestAllowed := true
 
+		// fetchBids activity
+		fetchBidsActivityAllowed := auctionReq.Activities.Allow(privacy.ActivityFetchBids,
+			privacy.ScopedName{Scope: privacy.ScopeTypeBidder, Name: bidderRequest.BidderName.String()})
+		if fetchBidsActivityAllowed == privacy.ActivityDeny {
+			// skip the call to a bidder if fetchBids activity is not allowed
+			// do not add this bidder to allowedBidderRequests
+			continue
+		}
+
 		// CCPA
 		privacyEnforcement.CCPA = ccpaEnforcer.ShouldEnforce(bidderRequest.BidderName.String())
 
diff --git a/privacy/activity.go b/privacy/activity.go
index 7cdef175..6ca48ae0 100644
--- a/privacy/activity.go
+++ b/privacy/activity.go
@@ -11,6 +11,7 @@ const (
 	ActivityTransmitUserFPD
 	ActivityTransmitPreciseGeo
 	ActivityTransmitUniqueRequestIds
+	ActivityTransmitTids
 )
 
 func (a Activity) String() string {
@@ -29,6 +30,8 @@ func (a Activity) String() string {
 		return "transmitPreciseGeo"
 	case ActivityTransmitUniqueRequestIds:
 		return "transmitUniqueRequestIds"
+	case ActivityTransmitTids:
+		return "transmitTid"
 	}
 
 	return ""
diff --git a/privacy/enforcer.go b/privacy/enforcer.go
index 0d5ecad5..d63cd8de 100644
--- a/privacy/enforcer.go
+++ b/privacy/enforcer.go
@@ -1,43 +1,236 @@
 package privacy
 
-// PolicyEnforcer determines if personally identifiable information (PII) should be removed or anonymized per the policy.
-type PolicyEnforcer interface {
-	// CanEnforce returns true when policy information is specifically provided by the publisher.
-	CanEnforce() bool
+import (
+	"fmt"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/errortypes"
+	"strings"
+)
 
-	// ShouldEnforce returns true when the OpenRTB request should have personally identifiable
-	// information (PII) removed or anonymized per the policy.
-	ShouldEnforce(bidder string) bool
+type ActivityResult int
+
+const (
+	ActivityAbstain ActivityResult = iota
+	ActivityAllow
+	ActivityDeny
+)
+
+const (
+	ScopeTypeBidder    = "bidder"
+	ScopeTypeAnalytics = "analytics"
+	ScopeTypeRTD       = "rtd" // real time data
+	ScopeTypeUserID    = "userid"
+	ScopeTypeGeneral   = "general"
+)
+
+type ActivityControl struct {
+	plans map[Activity]ActivityPlan
 }
 
-// NilPolicyEnforcer implements the PolicyEnforcer interface but will always return false.
-type NilPolicyEnforcer struct{}
+func NewActivityControl(privacyConf *config.AccountPrivacy) (ActivityControl, error) {
+	ac := ActivityControl{}
+	var err error
 
-// CanEnforce is hardcoded to always return false.
-func (NilPolicyEnforcer) CanEnforce() bool {
-	return false
+	if privacyConf == nil {
+		return ac, err
+	} else {
+		//temporarily disable Activities if they are specified at the account level
+		return ac, &errortypes.Warning{Message: "account.Privacy has no effect as the feature is under development."}
+	}
+
+	plans := make(map[Activity]ActivityPlan)
+
+	plans[ActivitySyncUser], err = buildEnforcementPlan(privacyConf.AllowActivities.SyncUser)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityFetchBids], err = buildEnforcementPlan(privacyConf.AllowActivities.FetchBids)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityEnrichUserFPD], err = buildEnforcementPlan(privacyConf.AllowActivities.EnrichUserFPD)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityReportAnalytics], err = buildEnforcementPlan(privacyConf.AllowActivities.ReportAnalytics)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityTransmitUserFPD], err = buildEnforcementPlan(privacyConf.AllowActivities.TransmitUserFPD)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityTransmitPreciseGeo], err = buildEnforcementPlan(privacyConf.AllowActivities.TransmitPreciseGeo)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityTransmitUniqueRequestIds], err = buildEnforcementPlan(privacyConf.AllowActivities.TransmitUniqueRequestIds)
+	if err != nil {
+		return ac, err
+	}
+	plans[ActivityTransmitTids], err = buildEnforcementPlan(privacyConf.AllowActivities.TransmitTids)
+	if err != nil {
+		return ac, err
+	}
+
+	ac.plans = plans
+
+	return ac, nil
+}
+
+func buildEnforcementPlan(activity config.Activity) (ActivityPlan, error) {
+	ef := ActivityPlan{}
+	rules, err := activityRulesToEnforcementRules(activity.Rules)
+	if err != nil {
+		return ef, err
+	}
+	ef.defaultResult = activityDefaultToDefaultResult(activity.Default)
+	ef.rules = rules
+	return ef, nil
 }
 
-// ShouldEnforce is hardcoded to always return false.
-func (NilPolicyEnforcer) ShouldEnforce(bidder string) bool {
-	return false
+func activityRulesToEnforcementRules(rules []config.ActivityRule) ([]ActivityRule, error) {
+	enfRules := make([]ActivityRule, 0)
+	for _, r := range rules {
+		cmpName, err := conditionToRuleComponentName(r.Condition.ComponentName)
+		if err != nil {
+			return nil, err
+		}
+		er := ComponentEnforcementRule{
+			allowed:       r.Allow,
+			componentName: cmpName,
+			componentType: r.Condition.ComponentType,
+		}
+		enfRules = append(enfRules, er)
+	}
+	return enfRules, nil
 }
 
-// EnabledPolicyEnforcer decorates a PolicyEnforcer with an enabled flag.
-type EnabledPolicyEnforcer struct {
-	Enabled        bool
-	PolicyEnforcer PolicyEnforcer
+func conditionToRuleComponentName(conditions []string) ([]ScopedName, error) {
+	sn := make([]ScopedName, 0)
+	for _, condition := range conditions {
+		scope, err := NewScopedName(condition)
+		if err != nil {
+			return sn, err
+		}
+		sn = append(sn, scope)
+	}
+	return sn, nil
 }
 
-// CanEnforce returns true when the PolicyEnforcer can enforce.
-func (p EnabledPolicyEnforcer) CanEnforce() bool {
-	return p.PolicyEnforcer.CanEnforce()
+func activityDefaultToDefaultResult(activityDefault *bool) ActivityResult {
+	if activityDefault == nil {
+		// if default is unspecified, the hardcoded default-default is true.
+		return ActivityAllow
+	} else if *activityDefault {
+		return ActivityAllow
+	}
+	return ActivityDeny
 }
 
-// ShouldEnforce returns true when the enforcer is enabled the PolicyEnforcer allows enforcement.
-func (p EnabledPolicyEnforcer) ShouldEnforce(bidder string) bool {
-	if p.Enabled {
-		return p.PolicyEnforcer.ShouldEnforce(bidder)
+func (e ActivityControl) Allow(activity Activity, target ScopedName) ActivityResult {
+	plan, planDefined := e.plans[activity]
+
+	if !planDefined {
+		return ActivityAbstain
 	}
-	return false
+
+	return plan.Allow(target)
+}
+
+type ActivityPlan struct {
+	defaultResult ActivityResult
+	rules         []ActivityRule
+}
+
+func (p ActivityPlan) Allow(target ScopedName) ActivityResult {
+	for _, rule := range p.rules {
+		result := rule.Allow(target)
+		if result == ActivityDeny || result == ActivityAllow {
+			return result
+		}
+	}
+	return p.defaultResult
+}
+
+type ActivityRule interface {
+	Allow(target ScopedName) ActivityResult
+}
+
+type ComponentEnforcementRule struct {
+	componentName []ScopedName
+	componentType []string
+	// include gppSectionId from 3.5
+	// include geo from 3.5
+	allowed bool
+}
+
+func (r ComponentEnforcementRule) Allow(target ScopedName) ActivityResult {
+	if len(r.componentName) == 0 && len(r.componentType) == 0 {
+		return ActivityAbstain
+	}
+
+	nameClauseExists := len(r.componentName) > 0
+	typeClauseExists := len(r.componentType) > 0
+
+	componentNameFound := false
+	for _, scope := range r.componentName {
+		if strings.EqualFold(scope.Scope, target.Scope) &&
+			(scope.Name == "*" || strings.EqualFold(scope.Name, target.Name)) {
+			componentNameFound = true
+			break
+		}
+	}
+
+	componentTypeFound := false
+	for _, componentType := range r.componentType {
+		if strings.EqualFold(componentType, target.Scope) {
+			componentTypeFound = true
+			break
+		}
+	}
+	// behavior if rule matches: can be either true=allow or false=deny. result is abstain if the rule doesn't match
+	matchFound := (componentNameFound || !nameClauseExists) && (componentTypeFound || !typeClauseExists)
+	if matchFound {
+		if r.allowed {
+			return ActivityAllow
+		} else {
+			return ActivityDeny
+		}
+	}
+	return ActivityAbstain
+}
+
+type ScopedName struct {
+	Scope string
+	Name  string
+}
+
+func NewScopedName(condition string) (ScopedName, error) {
+	if condition == "" {
+		return ScopedName{}, fmt.Errorf("unable to parse empty condition")
+	}
+	var scope, name string
+	split := strings.Split(condition, ".")
+	if len(split) == 2 {
+		s := strings.ToLower(split[0])
+		if s == ScopeTypeBidder || s == ScopeTypeAnalytics || s == ScopeTypeUserID {
+			scope = s
+		} else if strings.Contains(s, ScopeTypeRTD) {
+			scope = ScopeTypeRTD
+		} else {
+			scope = ScopeTypeGeneral
+		}
+		name = split[1]
+	} else if len(split) == 1 {
+		scope = ScopeTypeBidder
+		name = split[0]
+	} else {
+		return ScopedName{}, fmt.Errorf("unable to parse condition: %s", condition)
+	}
+
+	return ScopedName{
+		Scope: scope,
+		Name:  name,
+	}, nil
 }
diff --git a/privacy/policyenforcer.go b/privacy/policyenforcer.go
new file mode 100644
index 00000000..e70c0d3d
--- /dev/null
+++ b/privacy/policyenforcer.go
@@ -0,0 +1,45 @@
+package privacy
+
+// NOTE: Reanme this package. Will eventually replace in its entirety with Activites.
+
+// PolicyEnforcer determines if personally identifiable information (PII) should be removed or anonymized per the policy.
+type PolicyEnforcer interface {
+	// CanEnforce returns true when policy information is specifically provided by the publisher.
+	CanEnforce() bool
+
+	// ShouldEnforce returns true when the OpenRTB request should have personally identifiable
+	// information (PII) removed or anonymized per the policy.
+	ShouldEnforce(bidder string) bool
+}
+
+// NilPolicyEnforcer implements the PolicyEnforcer interface but will always return false.
+type NilPolicyEnforcer struct{}
+
+// CanEnforce is hardcoded to always return false.
+func (NilPolicyEnforcer) CanEnforce() bool {
+	return false
+}
+
+// ShouldEnforce is hardcoded to always return false.
+func (NilPolicyEnforcer) ShouldEnforce(bidder string) bool {
+	return false
+}
+
+// EnabledPolicyEnforcer decorates a PolicyEnforcer with an enabled flag.
+type EnabledPolicyEnforcer struct {
+	Enabled        bool
+	PolicyEnforcer PolicyEnforcer
+}
+
+// CanEnforce returns true when the PolicyEnforcer can enforce.
+func (p EnabledPolicyEnforcer) CanEnforce() bool {
+	return p.PolicyEnforcer.CanEnforce()
+}
+
+// ShouldEnforce returns true when the enforcer is enabled the PolicyEnforcer allows enforcement.
+func (p EnabledPolicyEnforcer) ShouldEnforce(bidder string) bool {
+	if p.Enabled {
+		return p.PolicyEnforcer.ShouldEnforce(bidder)
+	}
+	return false
+}
diff --git a/static/bidder-info/pwbid.yaml b/static/bidder-info/pwbid.yaml
index ac687273..a172ff39 100644
--- a/static/bidder-info/pwbid.yaml
+++ b/static/bidder-info/pwbid.yaml
@@ -1,4 +1,4 @@
-endpoint: "https://bid.pubwise.io/prebid"
+endpoint: "https://bidder.east2.pubwise.io/bid/pubwisedirect"
 maintainer:
   email: info@pubwise.io
 gvlVendorID: 842
@@ -17,4 +17,4 @@ userSync:
   # PubWise supports user syncing, but requires configuration by the host. contact this
   # bidder directly at the email address in this file to ask about enabling user sync.
   supports:
-    - redirect
\ No newline at end of file
+    - redirect


Test patch:
diff --git a/adapters/pwbid/pwbid_test.go b/adapters/pwbid/pwbid_test.go
index 21a41942..194e4bde 100644
--- a/adapters/pwbid/pwbid_test.go
+++ b/adapters/pwbid/pwbid_test.go
@@ -10,7 +10,7 @@ import (
 
 func TestJsonSamples(t *testing.T) {
 	bidder, buildErr := Builder(openrtb_ext.BidderPWBid, config.Adapter{
-		Endpoint: "https://bid.pubwise.io/prebid"},
+		Endpoint: "https://bidder.east2.pubwise.io/bid/pubwisedirect"},
 		config.Server{ExternalUrl: "http://hosturl.com", GvlID: 842, DataCenter: "2"})
 
 	if buildErr != nil {
diff --git a/adapters/pwbid/pwbidtest/exemplary/banner.json b/adapters/pwbid/pwbidtest/exemplary/banner.json
index ba618cb8..4cf93e1a 100644
--- a/adapters/pwbid/pwbidtest/exemplary/banner.json
+++ b/adapters/pwbid/pwbidtest/exemplary/banner.json
@@ -23,7 +23,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id-banner",
           "imp": [
@@ -90,4 +90,4 @@
       ]
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/adapters/pwbid/pwbidtest/exemplary/native.json b/adapters/pwbid/pwbidtest/exemplary/native.json
index 907c16d4..ff57752c 100644
--- a/adapters/pwbid/pwbidtest/exemplary/native.json
+++ b/adapters/pwbid/pwbidtest/exemplary/native.json
@@ -18,7 +18,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id-native",
           "imp": [
@@ -78,4 +78,4 @@
       ]
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/adapters/pwbid/pwbidtest/exemplary/optional-params.json b/adapters/pwbid/pwbidtest/exemplary/optional-params.json
index a080be90..5ababb24 100644
--- a/adapters/pwbid/pwbidtest/exemplary/optional-params.json
+++ b/adapters/pwbid/pwbidtest/exemplary/optional-params.json
@@ -25,7 +25,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id-banner",
           "imp": [
@@ -94,4 +94,4 @@
       ]
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/adapters/pwbid/pwbidtest/exemplary/video.json b/adapters/pwbid/pwbidtest/exemplary/video.json
index b74c780d..6257de63 100644
--- a/adapters/pwbid/pwbidtest/exemplary/video.json
+++ b/adapters/pwbid/pwbidtest/exemplary/video.json
@@ -20,7 +20,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/pwbid/pwbidtest/supplemental/response-200-without-body.json b/adapters/pwbid/pwbidtest/supplemental/response-200-without-body.json
index 146ba93a..0d469893 100644
--- a/adapters/pwbid/pwbidtest/supplemental/response-200-without-body.json
+++ b/adapters/pwbid/pwbidtest/supplemental/response-200-without-body.json
@@ -19,7 +19,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/pwbid/pwbidtest/supplemental/response-204.json b/adapters/pwbid/pwbidtest/supplemental/response-204.json
index 5fff7ee3..4fc8961e 100644
--- a/adapters/pwbid/pwbidtest/supplemental/response-204.json
+++ b/adapters/pwbid/pwbidtest/supplemental/response-204.json
@@ -19,7 +19,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/pwbid/pwbidtest/supplemental/response-400.json b/adapters/pwbid/pwbidtest/supplemental/response-400.json
index d594e571..a1517883 100644
--- a/adapters/pwbid/pwbidtest/supplemental/response-400.json
+++ b/adapters/pwbid/pwbidtest/supplemental/response-400.json
@@ -19,7 +19,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/pwbid/pwbidtest/supplemental/response-500.json b/adapters/pwbid/pwbidtest/supplemental/response-500.json
index fa3d4d06..c2b56494 100644
--- a/adapters/pwbid/pwbidtest/supplemental/response-500.json
+++ b/adapters/pwbid/pwbidtest/supplemental/response-500.json
@@ -19,7 +19,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://bid.pubwise.io/prebid",
+        "uri": "https://bidder.east2.pubwise.io/bid/pubwisedirect",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index d1b6622f..973edc53 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/prebid/prebid-server/gdpr"
 	"github.com/prebid/prebid-server/metrics"
 	"github.com/prebid/prebid-server/openrtb_ext"
+	"github.com/prebid/prebid-server/privacy"
 	"github.com/prebid/prebid-server/util/ptrutil"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -4273,3 +4274,72 @@ func TestGetMediaTypeForBid(t *testing.T) {
 		})
 	}
 }
+
+func TemporarilyDisabledTestCleanOpenRTBRequestsActivitiesFetchBids(t *testing.T) {
+	testCases := []struct {
+		name              string
+		req               *openrtb2.BidRequest
+		componentName     string
+		allow             bool
+		expectedReqNumber int
+	}{
+		{
+			name:              "request_with_one_bidder_allowed",
+			req:               newBidRequest(t),
+			componentName:     "appnexus",
+			allow:             true,
+			expectedReqNumber: 1,
+		},
+		{
+			name:              "request_with_one_bidder_not_allowed",
+			req:               newBidRequest(t),
+			componentName:     "appnexus",
+			allow:             false,
+			expectedReqNumber: 0,
+		},
+	}
+
+	for _, test := range testCases {
+		privacyConfig := getDefaultActivityConfig(test.componentName, test.allow)
+		activities, err := privacy.NewActivityControl(privacyConfig)
+		assert.NoError(t, err, "")
+		auctionReq := AuctionRequest{
+			BidRequestWrapper: &openrtb_ext.RequestWrapper{BidRequest: test.req},
+			UserSyncs:         &emptyUsersync{},
+			Activities:        activities,
+		}
+
+		bidderToSyncerKey := map[string]string{}
+		reqSplitter := &requestSplitter{
+			bidderToSyncerKey: bidderToSyncerKey,
+			me:                &metrics.MetricsEngineMock{},
+			hostSChainNode:    nil,
+			bidderInfo:        config.BidderInfos{},
+		}
+
+		t.Run(test.name, func(t *testing.T) {
+			bidderRequests, _, errs := reqSplitter.cleanOpenRTBRequests(context.Background(), auctionReq, nil, gdpr.SignalNo)
+			assert.Empty(t, errs)
+			assert.Len(t, bidderRequests, test.expectedReqNumber)
+		})
+	}
+}
+
+func getDefaultActivityConfig(componentName string, allow bool) *config.AccountPrivacy {
+	return &config.AccountPrivacy{
+		AllowActivities: config.AllowActivities{
+			FetchBids: config.Activity{
+				Default: ptrutil.ToPtr(true),
+				Rules: []config.ActivityRule{
+					{
+						Allow: allow,
+						Condition: config.ActivityCondition{
+							ComponentName: []string{componentName},
+							ComponentType: []string{"bidder"},
+						},
+					},
+				},
+			},
+		},
+	}
+}
diff --git a/privacy/enforcer_test.go b/privacy/enforcer_test.go
index b0c4032c..e87a9eb2 100644
--- a/privacy/enforcer_test.go
+++ b/privacy/enforcer_test.go
@@ -1,18 +1,427 @@
 package privacy
 
 import (
-	"testing"
-
+	"errors"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/util/ptrutil"
 	"github.com/stretchr/testify/assert"
+	"testing"
 )
 
-func TestNilEnforcerCanEnforce(t *testing.T) {
-	nilEnforcer := &NilPolicyEnforcer{}
-	assert.False(t, nilEnforcer.CanEnforce())
+func TemporarilyDisabledTestNewActivityControl(t *testing.T) {
+
+	testCases := []struct {
+		name            string
+		privacyConf     *config.AccountPrivacy
+		activityControl ActivityControl
+		err             error
+	}{
+		{
+			name:            "privacy_config_is_nil",
+			privacyConf:     nil,
+			activityControl: ActivityControl{plans: nil},
+			err:             nil,
+		},
+		{
+			name: "privacy_config_is_specified_and_correct",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					SyncUser:                 getDefaultActivityConfig(),
+					FetchBids:                getDefaultActivityConfig(),
+					EnrichUserFPD:            getDefaultActivityConfig(),
+					ReportAnalytics:          getDefaultActivityConfig(),
+					TransmitUserFPD:          getDefaultActivityConfig(),
+					TransmitPreciseGeo:       getDefaultActivityConfig(),
+					TransmitUniqueRequestIds: getDefaultActivityConfig(),
+					TransmitTids:             getDefaultActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: map[Activity]ActivityPlan{
+				ActivitySyncUser:                 getDefaultActivityPlan(),
+				ActivityFetchBids:                getDefaultActivityPlan(),
+				ActivityEnrichUserFPD:            getDefaultActivityPlan(),
+				ActivityReportAnalytics:          getDefaultActivityPlan(),
+				ActivityTransmitUserFPD:          getDefaultActivityPlan(),
+				ActivityTransmitPreciseGeo:       getDefaultActivityPlan(),
+				ActivityTransmitUniqueRequestIds: getDefaultActivityPlan(),
+				ActivityTransmitTids:             getDefaultActivityPlan(),
+			}},
+			err: nil,
+		},
+		{
+			name: "privacy_config_is_specified_and_SyncUser_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					SyncUser: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_FetchBids_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					FetchBids: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_EnrichUserFPD_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					EnrichUserFPD: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_ReportAnalytics_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					ReportAnalytics: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_TransmitUserFPD_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					TransmitUserFPD: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_TransmitPreciseGeo_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					TransmitPreciseGeo: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_TransmitUniqueRequestIds_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					TransmitUniqueRequestIds: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name: "privacy_config_is_specified_and_TransmitTids_is_incorrect",
+			privacyConf: &config.AccountPrivacy{
+				AllowActivities: config.AllowActivities{
+					TransmitTids: getIncorrectActivityConfig(),
+				},
+			},
+			activityControl: ActivityControl{plans: nil},
+			err:             errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			actualAC, actualErr := NewActivityControl(test.privacyConf)
+			if test.err == nil {
+				assert.Equal(t, test.activityControl, actualAC)
+				assert.NoError(t, actualErr)
+			} else {
+				assert.EqualError(t, actualErr, test.err.Error())
+			}
+		})
+	}
+}
+
+func TestActivityDefaultToDefaultResult(t *testing.T) {
+
+	testCases := []struct {
+		name            string
+		activityDefault *bool
+		expectedResult  ActivityResult
+	}{
+		{
+			name:            "activityDefault_is_nil",
+			activityDefault: nil,
+			expectedResult:  ActivityAllow,
+		},
+		{
+			name:            "activityDefault_is_true",
+			activityDefault: ptrutil.ToPtr(true),
+			expectedResult:  ActivityAllow,
+		},
+		{
+			name:            "activityDefault_is_false",
+			activityDefault: ptrutil.ToPtr(false),
+			expectedResult:  ActivityDeny,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			actualResult := activityDefaultToDefaultResult(test.activityDefault)
+			assert.Equal(t, test.expectedResult, actualResult)
+		})
+	}
+}
+
+func TestAllowActivityControl(t *testing.T) {
+
+	testCases := []struct {
+		name            string
+		activityControl ActivityControl
+		activity        Activity
+		target          ScopedName
+		activityResult  ActivityResult
+	}{
+		{
+			name:            "plans_is_nil",
+			activityControl: ActivityControl{plans: nil},
+			activity:        ActivityFetchBids,
+			target:          ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult:  ActivityAbstain,
+		},
+		{
+			name: "activity_not_defined",
+			activityControl: ActivityControl{plans: map[Activity]ActivityPlan{
+				ActivitySyncUser: getDefaultActivityPlan()}},
+			activity:       ActivityFetchBids,
+			target:         ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult: ActivityAbstain,
+		},
+		{
+			name: "activity_defined_but_not_found_default_returned",
+			activityControl: ActivityControl{plans: map[Activity]ActivityPlan{
+				ActivityFetchBids: getDefaultActivityPlan()}},
+			activity:       ActivityFetchBids,
+			target:         ScopedName{Scope: "bidder", Name: "bidderB"},
+			activityResult: ActivityAllow,
+		},
+		{
+			name: "activity_defined_and_allowed",
+			activityControl: ActivityControl{plans: map[Activity]ActivityPlan{
+				ActivityFetchBids: getDefaultActivityPlan()}},
+			activity:       ActivityFetchBids,
+			target:         ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult: ActivityAllow,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			actualResult := test.activityControl.Allow(test.activity, test.target)
+			assert.Equal(t, test.activityResult, actualResult)
+
+		})
+	}
+}
+
+func TestAllowComponentEnforcementRule(t *testing.T) {
+
+	testCases := []struct {
+		name           string
+		componentRule  ComponentEnforcementRule
+		target         ScopedName
+		activityResult ActivityResult
+	}{
+		{
+			name: "activity_is_allowed",
+			componentRule: ComponentEnforcementRule{
+				allowed: true,
+				componentName: []ScopedName{
+					{Scope: "bidder", Name: "bidderA"},
+				},
+				componentType: []string{"bidder"},
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult: ActivityAllow,
+		},
+		{
+			name: "activity_is_not_allowed",
+			componentRule: ComponentEnforcementRule{
+				allowed: false,
+				componentName: []ScopedName{
+					{Scope: "bidder", Name: "bidderA"},
+				},
+				componentType: []string{"bidder"},
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult: ActivityDeny,
+		},
+		{
+			name: "abstain_both_clauses_do_not_match",
+			componentRule: ComponentEnforcementRule{
+				allowed: true,
+				componentName: []ScopedName{
+					{Scope: "bidder", Name: "bidderA"},
+				},
+				componentType: []string{"bidder"},
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderB"},
+			activityResult: ActivityAbstain,
+		},
+		{
+			name: "activity_is_not_allowed_componentName_only",
+			componentRule: ComponentEnforcementRule{
+				allowed: true,
+				componentName: []ScopedName{
+					{Scope: "bidder", Name: "bidderA"},
+				},
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderA"},
+			activityResult: ActivityAllow,
+		},
+		{
+			name: "activity_is_allowed_componentType_only",
+			componentRule: ComponentEnforcementRule{
+				allowed:       true,
+				componentType: []string{"bidder"},
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderB"},
+			activityResult: ActivityAllow,
+		},
+		{
+			name: "abstain_activity_no_componentType_and_no_componentName",
+			componentRule: ComponentEnforcementRule{
+				allowed: true,
+			},
+			target:         ScopedName{Scope: "bidder", Name: "bidderB"},
+			activityResult: ActivityAbstain,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			actualResult := test.componentRule.Allow(test.target)
+			assert.Equal(t, test.activityResult, actualResult)
+
+		})
+	}
+}
+
+func TestNewScopedName(t *testing.T) {
+
+	testCases := []struct {
+		name              string
+		condition         string
+		expectedScopeName ScopedName
+		err               error
+	}{
+		{
+			name:              "condition_is_empty",
+			condition:         "",
+			expectedScopeName: ScopedName{},
+			err:               errors.New("unable to parse empty condition"),
+		},
+		{
+			name:              "condition_is_incorrect",
+			condition:         "bidder.bidderA.bidderB",
+			expectedScopeName: ScopedName{},
+			err:               errors.New("unable to parse condition: bidder.bidderA.bidderB"),
+		},
+		{
+			name:              "condition_is_scoped_to_bidder",
+			condition:         "bidder.bidderA",
+			expectedScopeName: ScopedName{Scope: "bidder", Name: "bidderA"},
+			err:               nil,
+		},
+		{
+			name:              "condition_is_scoped_to_analytics",
+			condition:         "analytics.bidderA",
+			expectedScopeName: ScopedName{Scope: "analytics", Name: "bidderA"},
+			err:               nil,
+		},
+		{
+			name:              "condition_is_scoped_to_userid",
+			condition:         "userid.bidderA",
+			expectedScopeName: ScopedName{Scope: "userid", Name: "bidderA"},
+			err:               nil,
+		},
+		{
+			name:              "condition_is_bidder_name",
+			condition:         "bidderA",
+			expectedScopeName: ScopedName{Scope: "bidder", Name: "bidderA"},
+			err:               nil,
+		},
+		{
+			name:              "condition_is_module_tag_rtd",
+			condition:         "rtd.test",
+			expectedScopeName: ScopedName{Scope: "rtd", Name: "test"},
+			err:               nil,
+		},
+		{
+			name:              "condition_scope_defaults_to_genera",
+			condition:         "test.test",
+			expectedScopeName: ScopedName{Scope: "general", Name: "test"},
+			err:               nil,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			actualSN, actualErr := NewScopedName(test.condition)
+			if test.err == nil {
+				assert.Equal(t, test.expectedScopeName, actualSN)
+				assert.NoError(t, actualErr)
+			} else {
+				assert.EqualError(t, actualErr, test.err.Error())
+			}
+		})
+	}
+}
+
+// constants
+func getDefaultActivityConfig() config.Activity {
+	return config.Activity{
+		Default: ptrutil.ToPtr(true),
+		Rules: []config.ActivityRule{
+			{
+				Allow: true,
+				Condition: config.ActivityCondition{
+					ComponentName: []string{"bidderA"},
+					ComponentType: []string{"bidder"},
+				},
+			},
+		},
+	}
+}
+
+func getDefaultActivityPlan() ActivityPlan {
+	return ActivityPlan{
+		defaultResult: ActivityAllow,
+		rules: []ActivityRule{
+			ComponentEnforcementRule{
+				allowed: true,
+				componentName: []ScopedName{
+					{Scope: "bidder", Name: "bidderA"},
+				},
+				componentType: []string{"bidder"},
+			},
+		},
+	}
 }
 
-func TestNilEnforcerShouldEnforce(t *testing.T) {
-	nilEnforcer := &NilPolicyEnforcer{}
-	assert.False(t, nilEnforcer.ShouldEnforce(""))
-	assert.False(t, nilEnforcer.ShouldEnforce("anyBidder"))
+func getIncorrectActivityConfig() config.Activity {
+	return config.Activity{
+		Default: ptrutil.ToPtr(true),
+		Rules: []config.ActivityRule{
+			{
+				Allow: true,
+				Condition: config.ActivityCondition{
+					ComponentName: []string{"bidder.bidderA.bidderB"},
+					ComponentType: []string{"bidder"},
+				},
+			},
+		},
+	}
 }
diff --git a/privacy/policyenforcer_test.go b/privacy/policyenforcer_test.go
new file mode 100644
index 00000000..b0c4032c
--- /dev/null
+++ b/privacy/policyenforcer_test.go
@@ -0,0 +1,18 @@
+package privacy
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNilEnforcerCanEnforce(t *testing.T) {
+	nilEnforcer := &NilPolicyEnforcer{}
+	assert.False(t, nilEnforcer.CanEnforce())
+}
+
+func TestNilEnforcerShouldEnforce(t *testing.T) {
+	nilEnforcer := &NilPolicyEnforcer{}
+	assert.False(t, nilEnforcer.ShouldEnforce(""))
+	assert.False(t, nilEnforcer.ShouldEnforce("anyBidder"))
+}