From 23fffa11d0b832ed226a1ded70e3dcfd799c500f Mon Sep 17 00:00:00 2001 From: Veronika Solovei Date: Thu, 6 Jul 2023 14:59:33 -0700 Subject: [PATCH] Activities framework (#2844) --- config/account.go | 2 +- config/activity.go | 3 +- config/config.go | 4 + endpoints/openrtb2/amp_auction.go | 9 + endpoints/openrtb2/auction.go | 11 + exchange/exchange.go | 2 + exchange/utils.go | 9 + exchange/utils_test.go | 70 +++++ privacy/activity.go | 3 + privacy/enforcer.go | 247 +++++++++++++++-- privacy/enforcer_test.go | 427 +++++++++++++++++++++++++++++- privacy/policyenforcer.go | 45 ++++ privacy/policyenforcer_test.go | 18 ++ 13 files changed, 812 insertions(+), 38 deletions(-) create mode 100644 privacy/policyenforcer.go create mode 100644 privacy/policyenforcer_test.go diff --git a/config/account.go b/config/account.go index 8beff9b6569..020402114de 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 987cbe84a2d..5bddc7c6405 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 ee18c746e88..543d1afd912 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 73f4842ae83..8d328b7c51a 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 216bff6eaf5..3f01967a4c2 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 24ca193f989..07dce45a185 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 d43a4182451..5b29fd6292f 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/exchange/utils_test.go b/exchange/utils_test.go index d1b6622f910..973edc53355 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/activity.go b/privacy/activity.go index 7cdef17590c..6ca48ae0b93 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 0d5ecad5309..d63cd8de31f 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/enforcer_test.go b/privacy/enforcer_test.go index b0c4032c714..e87a9eb2bff 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.go b/privacy/policyenforcer.go new file mode 100644 index 00000000000..e70c0d3d190 --- /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/privacy/policyenforcer_test.go b/privacy/policyenforcer_test.go new file mode 100644 index 00000000000..b0c4032c714 --- /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")) +}