Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TCF Purpose 1 and Purpose 2 enforcement for Prebid v4.0 #5336

Merged
merged 25 commits into from
Jun 24, 2020
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3042c1c
TCF v2.0 enforcement
Fawke Apr 30, 2020
73d3b52
test/spec/modules/gdprEnforcement_spec.js
Fawke Apr 30, 2020
a49f7e1
add check for gdpr version
Fawke May 5, 2020
559724e
add logInfo message
Fawke May 5, 2020
d945a8a
remove comment and store value of PURPOSES in an object
Fawke May 7, 2020
06e46d2
add gvlid check
Fawke May 11, 2020
f1fcf31
merge with master - change in validateRules function
Fawke May 28, 2020
cc57db8
add unit tests for validateRules function
Fawke Jun 2, 2020
9b98e98
remove purposeId parameter from validateRules function
Fawke Jun 2, 2020
87cfe20
merge with master
Fawke Jun 4, 2020
859d782
add extra tests
Fawke Jun 4, 2020
4e3978c
merge with master
Fawke Jun 4, 2020
1a0f5a7
make failing unit test case pass
Fawke Jun 4, 2020
ae9a237
deprecate allowAuctionWithouConsent with tcf 2 workflow
Fawke Jun 5, 2020
82e80a8
add extra checks for defaults
Fawke Jun 5, 2020
5311bac
remove tcf 2 test page
Fawke Jun 5, 2020
358a556
add strict gvlid check
Fawke Jun 8, 2020
d584a35
add comments and shorten log messages
Fawke Jun 9, 2020
0b461fb
shorted log messages
Fawke Jun 9, 2020
32c0682
add unit tests for setEnforcementConfig
Fawke Jun 9, 2020
35a6729
Merge remote-tracking branch 'origin/prebid-4.0' into tcf-purpose2
Jun 13, 2020
e7f14c0
add gvlid for alias and gvlMapping support
Jun 15, 2020
d0abaf0
remove gvlid check
Fawke Jun 15, 2020
45e9fb6
add support to add gvlid for aliases
Jun 17, 2020
beb7b65
Merge branch 'tcf-purpose2' of github.com:prebid/Prebid.js into tcf-p…
Jun 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ const DEFAULT_CMP = 'iab';
const DEFAULT_CONSENT_TIMEOUT = 10000;
const DEFAULT_ALLOW_AUCTION_WO_CONSENT = true;

export const allowAuction = {
value: DEFAULT_ALLOW_AUCTION_WO_CONSENT,
definedInConfig: false
}
export let userCMP;
export let consentTimeout;
export let allowAuction;
export let gdprScope;
export let staticConsentData;

Expand Down Expand Up @@ -322,6 +325,13 @@ function processCmpData(consentObject, hookConfig) {
// determine which set of checks to run based on cmpVersion
let checkFn = (cmpVersion === 1) ? checkV1Data : (cmpVersion === 2) ? checkV2Data : null;

// Raise deprecation warning if 'allowAuctionWithoutConsent' is used with TCF 2.
if (allowAuction.definedInConfig && cmpVersion === 2) {
utils.logWarn(`'allowAuctionWithoutConsent' ignored for TCF 2`);
} else if (!allowAuction.definedInConfig && cmpVersion === 1) {
utils.logInfo(`'allowAuctionWithoutConsent' using system default: (${DEFAULT_ALLOW_AUCTION_WO_CONSENT}).`);
}

if (utils.isFn(checkFn)) {
if (checkFn(consentObject)) {
cmpFailed(`CMP returned unexpected value during lookup process.`, hookConfig, consentObject);
Expand Down Expand Up @@ -352,7 +362,7 @@ function cmpFailed(errMsg, hookConfig, extraArgs) {
clearTimeout(hookConfig.timer);

// still set the consentData to undefined when there is a problem as per config options
if (allowAuction) {
if (allowAuction.value && cmpVersion === 1) {
storeConsentData(undefined);
}
exitModule(errMsg, hookConfig, extraArgs);
Expand Down Expand Up @@ -406,8 +416,8 @@ function exitModule(errMsg, hookConfig, extraArgs) {
let nextFn = hookConfig.nextFn;

if (errMsg) {
if (allowAuction) {
utils.logWarn(errMsg + ' Resuming auction without consent data as per consentManagement config.', extraArgs);
if (allowAuction.value && cmpVersion === 1) {
utils.logWarn(errMsg + ` 'allowAuctionWithoutConsent' activated.`, extraArgs);
nextFn.apply(context, args);
} else {
utils.logError(errMsg + ' Canceling auction as per consentManagement config.', extraArgs);
Expand Down Expand Up @@ -460,10 +470,8 @@ export function setConsentConfig(config) {
}

if (typeof config.allowAuctionWithoutConsent === 'boolean') {
allowAuction = config.allowAuctionWithoutConsent;
} else {
allowAuction = DEFAULT_ALLOW_AUCTION_WO_CONSENT;
utils.logInfo(`consentManagement config did not specify allowAuctionWithoutConsent. Using system default setting (${DEFAULT_ALLOW_AUCTION_WO_CONSENT}).`);
allowAuction.value = config.allowAuctionWithoutConsent;
allowAuction.definedInConfig = true;
}

// if true, then gdprApplies should be set to true
Expand Down
201 changes: 151 additions & 50 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,97 @@ import includes from 'core-js-pure/features/array/includes.js';
import { registerSyncInner } from '../src/adapters/bidderFactory.js';
import { getHook } from '../src/hook.js';
import { validateStorageEnforcement } from '../src/storageManager.js';
import events from '../src/events.js';
import { EVENTS } from '../src/constants.json';

const purpose1 = 'storage';
const TCF2 = {
'purpose1': { id: 1, name: 'storage' },
'purpose2': { id: 2, name: 'basicAds' }
}

const DEFAULT_RULES = [{
purpose: 'storage',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
}, {
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
}];

export let purpose1Rule;
export let purpose2Rule;
let addedDeviceAccessHook = false;
let enforcementRules;
export let enforcementRules;

function getGvlid() {
function getGvlid(bidderCode) {
let gvlid;
const bidderCode = config.getCurrentBidder();
bidderCode = bidderCode || config.getCurrentBidder();
if (bidderCode) {
const bidder = adapterManager.getBidAdapter(bidderCode);
gvlid = bidder.getSpec().gvlid;
} else {
utils.logWarn('Current module not found');
const gvlMapping = config.getConfig('gvlMapping');
if (gvlMapping && gvlMapping[bidderCode]) {
gvlid = gvlMapping[bidderCode];
} else {
const bidder = adapterManager.getBidAdapter(bidderCode);
if (bidder && bidder.getSpec) {
gvlid = bidder.getSpec().gvlid;
}
}
}
return gvlid;
}

function getGvlidForUserIdModule(userIdModule) {
let gvlId;
const gvlMapping = config.getConfig('gvlMapping');
if (gvlMapping && gvlMapping[userIdModule.name]) {
gvlId = gvlMapping[userIdModule.name];
} else {
gvlId = userIdModule.gvlid;
}
return gvlId;
}

/**
* This function takes in rules and consentData as input and validates against the consentData provided. If it returns true Prebid will allow the next call else it will log a warning
* @param {Object} rules enforcement rules set in config
* @param {Object} consentData gdpr consent data
* This function takes in a rule and consentData and validates against the consentData provided. Depending on what it returns,
* the caller may decide to suppress a TCF-sensitive activity.
* @param {Object} rule - enforcement rules set in config
* @param {Object} consentData - gdpr consent data
* @param {string=} currentModule - Bidder code of the current module
* @param {number=} gvlId - GVL ID for the module
* @returns {boolean}
*/
function validateRules(rule, consentData, currentModule, gvlid) {
// if vendor has exception => always true
export function validateRules(rule, consentData, currentModule, gvlId) {
const purposeId = TCF2[Object.keys(TCF2).filter(purposeName => TCF2[purposeName].name === rule.purpose)[0]].id;

// return 'true' if vendor present in 'vendorExceptions'
if (includes(rule.vendorExceptions || [], currentModule)) {
return true;
}
// if enforcePurpose is false or purpose was granted isAllowed is true, otherwise false
const purposeAllowed = rule.enforcePurpose === false || utils.deepAccess(consentData, 'vendorData.purpose.consents.1') === true;
// if enforceVendor is false or vendor was granted isAllowed is true, otherwise false
const vendorAllowed = rule.enforceVendor === false || utils.deepAccess(consentData, `vendorData.vendor.consents.${gvlid}`) === true;

Fawke marked this conversation as resolved.
Show resolved Hide resolved
// get data from the consent string
const purposeConsent = utils.deepAccess(consentData, `vendorData.purpose.consents.${purposeId}`);
const vendorConsent = utils.deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`);
const liTransparency = utils.deepAccess(consentData, `vendorData.purpose.legitimateInterests.${purposeId}`);

Fawke marked this conversation as resolved.
Show resolved Hide resolved
/*
Since vendor exceptions have already been handled, the purpose as a whole is allowed if it's not being enforced
or the user has consented. Similar with vendors.
*/
const purposeAllowed = rule.enforcePurpose === false || purposeConsent === true;
const vendorAllowed = rule.enforceVendor === false || vendorConsent === true;

Fawke marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: "Few if any vendors will be declaring Legitimate Interest for Purpose 1 (Device Access). However, a number of them do declare LI for Purpose 2 (Basic Ads). So if LI has been established, allow the auction and let the server side sort out the legal basis."

Copy link
Contributor Author

@Fawke Fawke Jun 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose this comment, which you mentioned earlier over this:

Comment: "Few if any vendors should be declaring Legitimate Interest for Device Access (Purpose 1), but some are claiming LI for Basic Ads (Purpose 2)". Prebid.js can't check to see who's declaring what legal basis, so if LI has been established for Purpose 2, allow the auction to take place and let the server sort out the legal basis calculation."

/*
Few if any vendors should be declaring Legitimate Interest for Device Access (Purpose 1), but some are claiming
LI for Basic Ads (Purpose 2). Prebid.js can't check to see who's declaring what legal basis, so if LI has been
established for Purpose 2, allow the auction to take place and let the server sort out the legal basis calculation.
*/
if (purposeId === 2) {
return (purposeAllowed && vendorAllowed) || (liTransparency === true);
}

return purposeAllowed && vendorAllowed;
}

Expand All @@ -65,22 +123,25 @@ export function deviceAccessHook(fn, gvlid, moduleName, result) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (consentData.apiVersion === 2) {
if (!gvlid) {
gvlid = getGvlid();
const curBidder = config.getCurrentBidder();
// Bidders have a copy of storage object with bidder code binded. Aliases will also pass the same bidder code when invoking storage functions and hence if alias tries to access device we will try to grab the gvl id for alias instead of original bidder
if (curBidder && (curBidder != moduleName) && adapterManager.aliasRegistry[curBidder] === moduleName) {
gvlid = getGvlid(curBidder);
} else {
gvlid = getGvlid(moduleName);
}
const curModule = moduleName || config.getCurrentBidder();
const purpose1Rule = find(enforcementRules, hasPurpose1);
const curModule = moduleName || curBidder;
let isAllowed = validateRules(purpose1Rule, consentData, curModule, gvlid);
if (isAllowed) {
result.valid = true;
fn.call(this, gvlid, moduleName, result);
} else {
utils.logWarn(`User denied Permission for Device access for ${curModule}`);
curModule && utils.logWarn(`Device access denied for ${curModule} by TCF2`);
result.valid = false;
fn.call(this, gvlid, moduleName, result);
}
} else {
utils.logInfo('Enforcing TCF2 only');
// The module doesn't enforce TCF1.1 strings
result.valid = true;
fn.call(this, gvlid, moduleName, result);
}
Expand All @@ -102,19 +163,14 @@ export function userSyncHook(fn, ...args) {
if (consentData.apiVersion === 2) {
const gvlid = getGvlid();
const curBidder = config.getCurrentBidder();
if (gvlid) {
const purpose1Rule = find(enforcementRules, hasPurpose1);
let isAllowed = validateRules(purpose1Rule, consentData, curBidder, gvlid);
if (isAllowed) {
fn.call(this, ...args);
} else {
utils.logWarn(`User sync not allowed for ${curBidder}`);
}
let isAllowed = validateRules(purpose1Rule, consentData, curBidder, gvlid);
if (isAllowed) {
fn.call(this, ...args);
} else {
utils.logWarn(`User sync not allowed for ${curBidder}`);
}
} else {
utils.logInfo('Enforcing TCF2 only');
// The module doesn't enforce TCF1.1 strings
fn.call(this, ...args);
}
} else {
Expand All @@ -132,53 +188,98 @@ export function userIdHook(fn, submodules, consentData) {
if (consentData && consentData.gdprApplies) {
if (consentData.apiVersion === 2) {
let userIdModules = submodules.map((submodule) => {
const gvlid = submodule.submodule.gvlid;
const gvlid = getGvlidForUserIdModule(submodule.submodule);
const moduleName = submodule.submodule.name;
if (gvlid) {
const purpose1Rule = find(enforcementRules, hasPurpose1);
let isAllowed = validateRules(purpose1Rule, consentData, moduleName, gvlid);
if (isAllowed) {
return submodule;
} else {
utils.logWarn(`User denied permission to fetch user id for ${moduleName} User id module`);
}
let isAllowed = validateRules(purpose1Rule, consentData, moduleName, gvlid);
if (isAllowed) {
return submodule;
} else {
utils.logWarn(`User denied permission to fetch user id for ${moduleName} User id module`);
}
return undefined;
}).filter(module => module)
fn.call(this, userIdModules, {...consentData, hasValidated: true});
} else {
utils.logInfo('Enforcing TCF2 only');
// The module doesn't enforce TCF1.1 strings
fn.call(this, submodules, consentData);
}
} else {
fn.call(this, submodules, consentData);
}
}

const hasPurpose1 = (rule) => { return rule.purpose === purpose1 }
/**
* Checks if a bidder is allowed in Auction.
* Enforces "purpose 2 (basic ads)" of TCF v2.0 spec
* @param {Function} fn - Function reference to the original function.
* @param {Array<adUnits>} adUnits
*/
export function makeBidRequestsHook(fn, adUnits, ...args) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (consentData.apiVersion === 2) {
const disabledBidders = [];
adUnits.forEach(adUnit => {
adUnit.bids = adUnit.bids.filter(bid => {
const currBidder = bid.bidder;
const gvlId = getGvlid(currBidder);
if (includes(disabledBidders, currBidder)) return false;
const isAllowed = !!validateRules(purpose2Rule, consentData, currBidder, gvlId);
if (!isAllowed) {
utils.logWarn(`TCF2 blocked auction for ${currBidder}`);
events.emit(EVENTS.BIDDER_BLOCKED, currBidder);
disabledBidders.push(currBidder);
}
return isAllowed;
});
});
fn.call(this, adUnits, ...args);
} else {
// The module doesn't enforce TCF1.1 strings
fn.call(this, adUnits, ...args);
}
} else {
fn.call(this, adUnits, ...args);
}
}

const hasPurpose1 = (rule) => { return rule.purpose === TCF2.purpose1.name }
const hasPurpose2 = (rule) => { return rule.purpose === TCF2.purpose2.name }

/**
* A configuration function that initializes some module variables, as well as add hooks
* @param {Object} config GDPR enforcement config object
* A configuration function that initializes some module variables, as well as adds hooks
* @param {Object} config - GDPR enforcement config object
*/
export function setEnforcementConfig(config) {
const rules = utils.deepAccess(config, 'gdpr.rules');
if (!rules) {
utils.logWarn('GDPR enforcement rules not defined, exiting enforcement module');
return;
utils.logWarn('TCF2: enforcing P1 and P2');
enforcementRules = DEFAULT_RULES;
} else {
enforcementRules = rules;
}

purpose1Rule = find(enforcementRules, hasPurpose1);
purpose2Rule = find(enforcementRules, hasPurpose2);

if (!purpose1Rule) {
purpose1Rule = DEFAULT_RULES[0];
}

enforcementRules = rules;
const hasDefinedPurpose1 = find(enforcementRules, hasPurpose1);
if (hasDefinedPurpose1 && !addedDeviceAccessHook) {
if (!purpose2Rule) {
purpose2Rule = DEFAULT_RULES[1];
}

if (purpose1Rule && !addedDeviceAccessHook) {
addedDeviceAccessHook = true;
validateStorageEnforcement.before(deviceAccessHook, 49);
registerSyncInner.before(userSyncHook, 48);
// Using getHook as user id and gdprEnforcement are both optional modules. Using import will auto include the file in build
getHook('validateGdprEnforcement').before(userIdHook, 47);
}
if (purpose2Rule) {
getHook('makeBidRequests').before(makeBidRequestsHook);
}
}

config.getConfig('consentManagement', config => setEnforcementConfig(config.consentManagement));
5 changes: 3 additions & 2 deletions src/adapterManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ adapterManager.registerBidAdapter = function (bidAdaptor, bidderCode, {supported
}
};

adapterManager.aliasBidAdapter = function (bidderCode, alias) {
adapterManager.aliasBidAdapter = function (bidderCode, alias, options) {
let existingAlias = _bidderRegistry[alias];

if (typeof existingAlias === 'undefined') {
Expand All @@ -452,7 +452,8 @@ adapterManager.aliasBidAdapter = function (bidderCode, alias) {
newAdapter.setBidderCode(alias);
} else {
let spec = bidAdaptor.getSpec();
newAdapter = newBidder(Object.assign({}, spec, { code: alias }));
let gvlid = options && options.gvlid;
newAdapter = newBidder(Object.assign({}, spec, { code: alias, gvlid }));
_aliasRegistry[alias] = bidderCode;
}
adapterManager.registerBidAdapter(newAdapter, alias, {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/bidderFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function registerBidder(spec) {
if (Array.isArray(spec.aliases)) {
spec.aliases.forEach(alias => {
adapterManager.aliasRegistry[alias] = spec.code;
putBidder(Object.assign({}, spec, { code: alias }));
putBidder(Object.assign({}, spec, { code: alias, gvlid: undefined }));
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"BEFORE_REQUEST_BIDS": "beforeRequestBids",
"REQUEST_BIDS": "requestBids",
"ADD_AD_UNITS": "addAdUnits",
"AD_RENDER_FAILED" : "adRenderFailed"
"AD_RENDER_FAILED" : "adRenderFailed",
"BIDDER_BLOCKED": "bidderBlocked"
},
"AD_RENDER_FAILED_REASON" : {
"PREVENT_WRITING_ON_MAIN_DOCUMENT": "preventWritingOnMainDocuemnt",
Expand Down
4 changes: 2 additions & 2 deletions src/prebid.js
Original file line number Diff line number Diff line change
Expand Up @@ -666,10 +666,10 @@ $$PREBID_GLOBAL$$.enableAnalytics = function (config) {
/**
* @alias module:pbjs.aliasBidder
*/
$$PREBID_GLOBAL$$.aliasBidder = function (bidderCode, alias) {
$$PREBID_GLOBAL$$.aliasBidder = function (bidderCode, alias, options) {
utils.logInfo('Invoking $$PREBID_GLOBAL$$.aliasBidder', arguments);
if (bidderCode && alias) {
adapterManager.aliasBidAdapter(bidderCode, alias);
adapterManager.aliasBidAdapter(bidderCode, alias, options);
} else {
utils.logError('bidderCode and alias must be passed as arguments', '$$PREBID_GLOBAL$$.aliasBidder');
}
Expand Down
Loading