Skip to content

Commit

Permalink
TCF Purpose 1 and Purpose 2 enforcement for Prebid v4.0 (#5336)
Browse files Browse the repository at this point in the history
* TCF v2.0 enforcement

* test/spec/modules/gdprEnforcement_spec.js

* add check for gdpr version

* add logInfo message

* remove comment and store value of PURPOSES in an object

* add gvlid check

* add unit tests for validateRules function

* remove purposeId parameter from validateRules function

* add extra tests

* make failing unit test case pass

* deprecate allowAuctionWithouConsent with tcf 2 workflow

* add extra checks for defaults

* remove tcf 2 test page

* add strict gvlid check

* add comments and shorten log messages

* shorted log messages

* add unit tests for setEnforcementConfig

* add gvlid for alias and gvlMapping support

* remove gvlid check

* add support to add gvlid for aliases

Co-authored-by: Jaimin Panchal <[email protected]>
  • Loading branch information
Fawke and Jaimin Panchal authored Jun 24, 2020
1 parent 5750058 commit eb2ef7b
Show file tree
Hide file tree
Showing 10 changed files with 814 additions and 159 deletions.
15 changes: 14 additions & 1 deletion modules/appnexusBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,20 @@ const storage = getStorageManager(GVLID, BIDDER_CODE);
export const spec = {
code: BIDDER_CODE,
gvlid: GVLID,
aliases: ['appnexusAst', 'brealtime', 'emxdigital', 'pagescience', 'defymedia', 'gourmetads', 'matomy', 'featureforward', 'oftmedia', 'districtm', 'adasta', 'beintoo'],
aliases: [
{ code: 'appnexusAst', gvlid: 32 },
{ code: 'brealtime' },
{ code: 'emxdigital', gvlid: 183 },
{ code: 'pagescience' },
{ code: 'defymedia' },
{ code: 'gourmetads' },
{ code: 'matomy' },
{ code: 'featureforward' },
{ code: 'oftmedia' },
{ code: 'districtm', gvlid: 144 },
{ code: 'adasta' },
{ code: 'beintoo', gvlid: 618 },
],
supportedMediaTypes: [BANNER, VIDEO, NATIVE],

/**
Expand Down
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;

// 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}`);

/*
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;

/*
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
Loading

0 comments on commit eb2ef7b

Please sign in to comment.