Skip to content

Commit

Permalink
TCFv2.0 Purpose 7 (prebid#5444)
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

* add enableAnalytics hook

* purpose 7 implementation: 1.hook added 2.new field to set gvlid for analytics adapters

* add enableAnalytics hook

* emit tcf2 events

* fix regression

* modify mechanism of event emitted after auction end

* add unit test for enableAnalyticsHook

* add unit test for auction end event

Co-authored-by: Jaimin Panchal <email@example.com>
  • Loading branch information
2 people authored and BrightMountainMedia committed Sep 14, 2020
1 parent f6ac6d6 commit 13eae5b
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 27 deletions.
3 changes: 2 additions & 1 deletion modules/appnexusAnalyticsAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ var appnexusAdapter = adapter({

adapterManager.registerAnalyticsAdapter({
adapter: appnexusAdapter,
code: 'appnexus'
code: 'appnexus',
gvlid: 32
});

export default appnexusAdapter;
119 changes: 109 additions & 10 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ import { EVENTS } from '../src/constants.json';

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

/*
These rules would be used if `consentManagement.gdpr.rules` is undefined by the publisher.
*/
const DEFAULT_RULES = [{
purpose: 'storage',
enforcePurpose: true,
Expand All @@ -33,9 +37,21 @@ const DEFAULT_RULES = [{

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

export let enforcementRules;

const storageBlocked = [];
const biddersBlocked = [];
const analyticsBlocked = [];

let addedDeviceAccessHook = false;

/**
* Returns gvlId for Bid Adapters. If a bidder does not have an associated gvlId, it returns 'undefined'.
* @param {string=} bidderCode - The 'code' property on the Bidder spec.
* @retuns {number} gvlId
*/
function getGvlid(bidderCode) {
let gvlid;
bidderCode = bidderCode || config.getCurrentBidder();
Expand All @@ -53,6 +69,11 @@ function getGvlid(bidderCode) {
return gvlid;
}

/**
* Returns gvlId for userId module. If a userId modules does not have an associated gvlId, it returns 'undefined'.
* @param {Object} userIdModule
* @retuns {number} gvlId
*/
function getGvlidForUserIdModule(userIdModule) {
let gvlId;
const gvlMapping = config.getConfig('gvlMapping');
Expand All @@ -64,6 +85,22 @@ function getGvlidForUserIdModule(userIdModule) {
return gvlId;
}

/**
* Returns gvlId for analytics adapters. If a analytics adapter does not have an associated gvlId, it returns 'undefined'.
* @param {string} code - 'provider' property on the analytics adapter config
* @returns {number} gvlId
*/
function getGvlidForAnalyticsAdapter(code) {
let gvlId;
const gvlMapping = config.getConfig('gvlMapping');
if (gvlMapping && gvlMapping[code]) {
gvlId = gvlMapping[code];
} else {
gvlId = adapterManager.getAnalyticsAdapter(code).gvlid;
}
return gvlId;
}

/**
* 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.
Expand Down Expand Up @@ -136,8 +173,9 @@ export function deviceAccessHook(fn, gvlid, moduleName, result) {
result.valid = true;
fn.call(this, gvlid, moduleName, result);
} else {
curModule && utils.logWarn(`Device access denied for ${curModule} by TCF2`);
curModule && utils.logWarn(`TCF2 denied device access for ${curModule}`);
result.valid = false;
storageBlocked.push(curModule);
fn.call(this, gvlid, moduleName, result);
}
} else {
Expand Down Expand Up @@ -168,6 +206,7 @@ export function userSyncHook(fn, ...args) {
fn.call(this, ...args);
} else {
utils.logWarn(`User sync not allowed for ${curBidder}`);
storageBlocked.push(curBidder);
}
} else {
// The module doesn't enforce TCF1.1 strings
Expand Down Expand Up @@ -195,10 +234,11 @@ export function userIdHook(fn, submodules, consentData) {
return submodule;
} else {
utils.logWarn(`User denied permission to fetch user id for ${moduleName} User id module`);
storageBlocked.push(moduleName);
}
return undefined;
}).filter(module => module)
fn.call(this, userIdModules, {...consentData, hasValidated: true});
fn.call(this, userIdModules, { ...consentData, hasValidated: true });
} else {
// The module doesn't enforce TCF1.1 strings
fn.call(this, submodules, consentData);
Expand All @@ -209,26 +249,24 @@ export function userIdHook(fn, submodules, consentData) {
}

/**
* Checks if a bidder is allowed in Auction.
* Enforces "purpose 2 (basic ads)" of TCF v2.0 spec
* Checks if bidders are allowed in the 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;
if (includes(biddersBlocked, 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);
biddersBlocked.push(currBidder);
}
return isAllowed;
});
Expand All @@ -243,8 +281,64 @@ export function makeBidRequestsHook(fn, adUnits, ...args) {
}
}

/**
* Checks if Analytics adapters are allowed to send data to their servers for furhter processing.
* Enforces "purpose 7 (Measurement)" of TCF v2.0 spec
* @param {Function} fn - Function reference to the original function.
* @param {Array<AnalyticsAdapterConfig>} config - Configuration object passed to pbjs.enableAnalytics()
*/
export function enableAnalyticsHook(fn, config) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (consentData.apiVersion === 2) {
if (!utils.isArray(config)) {
config = [config]
}
config = config.filter(conf => {
const analyticsAdapterCode = conf.provider;
const gvlid = getGvlidForAnalyticsAdapter(analyticsAdapterCode);
const isAllowed = !!validateRules(purpose7Rule, consentData, analyticsAdapterCode, gvlid);
if (!isAllowed) {
analyticsBlocked.push(analyticsAdapterCode);
utils.logWarn(`TCF2 blocked analytics adapter ${conf.provider}`);
}
return isAllowed;
});
fn.call(this, config);
} else {
// This module doesn't enforce TCF1.1 strings
fn.call(this, config);
}
} else {
fn.call(this, config);
}
}

/**
* Compiles the TCF2.0 enforcement results into an object, which is emitted as an event payload to "tcf2Enforcement" event.
*/
function emitTCF2FinalResults() {
// remove null and duplicate values
const formatArray = function (arr) {
return arr.filter((i, k) => i !== null && arr.indexOf(i) === k);
}
const tcf2FinalResults = {
storageBlocked: formatArray(storageBlocked),
biddersBlocked: formatArray(biddersBlocked),
analyticsBlocked: formatArray(analyticsBlocked)
};

events.emit(EVENTS.TCF2_ENFORCEMENT, tcf2FinalResults);
}

events.on(EVENTS.AUCTION_END, emitTCF2FinalResults);

/*
Set of callback functions used to detect presence of a TCF rule, passed as the second argument to find().
*/
const hasPurpose1 = (rule) => { return rule.purpose === TCF2.purpose1.name }
const hasPurpose2 = (rule) => { return rule.purpose === TCF2.purpose2.name }
const hasPurpose7 = (rule) => { return rule.purpose === TCF2.purpose7.name }

/**
* A configuration function that initializes some module variables, as well as adds hooks
Expand All @@ -261,6 +355,7 @@ export function setEnforcementConfig(config) {

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

if (!purpose1Rule) {
purpose1Rule = DEFAULT_RULES[0];
Expand All @@ -280,6 +375,10 @@ export function setEnforcementConfig(config) {
if (purpose2Rule) {
getHook('makeBidRequests').before(makeBidRequestsHook);
}

if (purpose7Rule) {
getHook('enableAnalyticsCb').before(enableAnalyticsHook);
}
}

config.getConfig('consentManagement', config => setEnforcementConfig(config.consentManagement));
12 changes: 8 additions & 4 deletions src/adapterManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,11 @@ adapterManager.aliasBidAdapter = function (bidderCode, alias, options) {
}
};

adapterManager.registerAnalyticsAdapter = function ({adapter, code}) {
adapterManager.registerAnalyticsAdapter = function ({adapter, code, gvlid}) {
if (adapter && code) {
if (typeof adapter.enableAnalytics === 'function') {
adapter.code = code;
_analyticsRegistry[code] = adapter;
_analyticsRegistry[code] = { adapter, gvlid };
} else {
utils.logError(`Prebid Error: Analytics adaptor error for analytics "${code}"
analytics adapter must implement an enableAnalytics() function`);
Expand All @@ -488,20 +488,24 @@ adapterManager.enableAnalytics = function (config) {
}

utils._each(config, adapterConfig => {
var adapter = _analyticsRegistry[adapterConfig.provider];
var adapter = _analyticsRegistry[adapterConfig.provider].adapter;
if (adapter) {
adapter.enableAnalytics(adapterConfig);
} else {
utils.logError(`Prebid Error: no analytics adapter found in registry for
${adapterConfig.provider}.`);
}
});
};
}

adapterManager.getBidAdapter = function(bidder) {
return _bidderRegistry[bidder];
};

adapterManager.getAnalyticsAdapter = function(code) {
return _analyticsRegistry[code];
}

// the s2sTesting module is injected when it's loaded rather than being imported
// importing it causes the packager to include it even when it's not explicitly included in the build
export function setS2STestingModule(module) {
Expand Down
4 changes: 2 additions & 2 deletions src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"BEFORE_REQUEST_BIDS": "beforeRequestBids",
"REQUEST_BIDS": "requestBids",
"ADD_AD_UNITS": "addAdUnits",
"AD_RENDER_FAILED" : "adRenderFailed",
"BIDDER_BLOCKED": "bidderBlocked"
"AD_RENDER_FAILED": "adRenderFailed",
"TCF2_ENFORCEMENT": "tcf2Enforcement"
},
"AD_RENDER_FAILED_REASON" : {
"PREVENT_WRITING_ON_MAIN_DOCUMENT": "preventWritingOnMainDocuemnt",
Expand Down
15 changes: 12 additions & 3 deletions src/prebid.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,9 @@ $$PREBID_GLOBAL$$.requestBids = hook('async', function ({ bidsBackHandler, timeo
auction.callBids();
});

export function executeStorageCallbacks(fn, reqBidsConfigObj) {
export function executeCallbacks(fn, reqBidsConfigObj) {
runAll(storageCallbacks);
runAll(enableAnalyticsCallbacks);
fn.call(this, reqBidsConfigObj);
function runAll(queue) {
var queued;
Expand All @@ -548,7 +549,7 @@ export function executeStorageCallbacks(fn, reqBidsConfigObj) {
}

// This hook will execute all storage callbacks which were registered before gdpr enforcement hook was added. Some bidders, user id modules use storage functions when module is parsed but gdpr enforcement hook is not added at that stage as setConfig callbacks are yet to be called. Hence for such calls we execute all the stored callbacks just before requestBids. At this hook point we will know for sure that gdprEnforcement module is added or not
$$PREBID_GLOBAL$$.requestBids.before(executeStorageCallbacks, 49);
$$PREBID_GLOBAL$$.requestBids.before(executeCallbacks, 49);

/**
*
Expand Down Expand Up @@ -667,13 +668,21 @@ $$PREBID_GLOBAL$$.createBid = function (statusCode) {
* @param {Object} config.options The options for this particular analytics adapter. This will likely vary between adapters.
* @alias module:pbjs.enableAnalytics
*/
$$PREBID_GLOBAL$$.enableAnalytics = function (config) {

// Stores 'enableAnalytics' callbacks for later execution.
const enableAnalyticsCallbacks = [];

const enableAnalyticsCb = hook('async', function (config) {
if (config && !utils.isEmpty(config)) {
utils.logInfo('Invoking $$PREBID_GLOBAL$$.enableAnalytics for: ', config);
adapterManager.enableAnalytics(config);
} else {
utils.logError('$$PREBID_GLOBAL$$.enableAnalytics should be called with option {}');
}
}, 'enableAnalyticsCb');

$$PREBID_GLOBAL$$.enableAnalytics = function (config) {
enableAnalyticsCallbacks.push(enableAnalyticsCb.bind(this, config));
};

/**
Expand Down
Loading

0 comments on commit 13eae5b

Please sign in to comment.