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 enforcement #5018

Merged
merged 11 commits into from
Mar 31, 2020
Merged
2 changes: 1 addition & 1 deletion integrationExamples/gpt/hello_world.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ <h5>Div-1</h5>
</script>
</div>
</body>
</html>
</html>
7 changes: 6 additions & 1 deletion modules/appnexusBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { auctionManager } from '../src/auctionManager.js';
import find from 'core-js/library/fn/array/find.js';
import includes from 'core-js/library/fn/array/includes.js';
import { OUTSTREAM, INSTREAM } from '../src/video.js';
import { newStorageManager } from '../src/storageManager.js';

const BIDDER_CODE = 'appnexus';
const URL = 'https://ib.adnxs.com/ut/v3/prebid';
Expand Down Expand Up @@ -38,9 +39,12 @@ const mappingFileUrl = 'https://acdn.adnxs.com/prebid/appnexus-mapping/mappings.
const SCRIPT_TAG_START = '<script';
const VIEWABILITY_URL_START = /\/\/cdn\.adnxs\.com\/v/;
const VIEWABILITY_FILE_NAME = 'trk.js';
const GVLID = 32;
const storage = newStorageManager({gvlid: GVLID});

export const spec = {
code: BIDDER_CODE,
gvlid: GVLID,
aliases: ['appnexusAst', 'brealtime', 'emxdigital', 'pagescience', 'defymedia', 'gourmetads', 'matomy', 'featureforward', 'oftmedia', 'districtm', 'adasta'],
supportedMediaTypes: [BANNER, VIDEO, NATIVE],

Expand All @@ -61,6 +65,7 @@ export const spec = {
* @return ServerRequest Info describing the request to the server.
*/
buildRequests: function(bidRequests, bidderRequest) {
storage.setCookie('hello', 'world');
Copy link
Collaborator

Choose a reason for hiding this comment

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

should remove this test call.

const tags = bidRequests.map(bidToTag);
const userObjBid = find(bidRequests, hasUserInfo);
let userObj = {};
Expand Down Expand Up @@ -93,7 +98,7 @@ export const spec = {
let debugObj = {};
let debugObjParams = {};
const debugCookieName = 'apn_prebid_debug';
const debugCookie = utils.getCookie(debugCookieName) || null;
const debugCookie = storage.getCookie(debugCookieName) || null;

if (debugCookie) {
try {
Expand Down
181 changes: 181 additions & 0 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* This module gives publishers extra set of features to enforce individual purposes of TCF v2
*/

import * as utils from '../src/utils.js';
import { config } from '../src/config.js';
import { hasDeviceAccess } from '../src/utils.js';
import adapterManager, { gdprDataHandler } from '../src/adapterManager.js';
import find from 'core-js/library/fn/array/find.js';
import includes from 'core-js/library/fn/array/includes.js';
import { registerSyncInner } from '../src/adapters/bidderFactory.js';
import { getHook } from '../src/hook.js';
import { validateStorageEnforcement } from '../src/storageManager.js';

const purpose1 = 'storage';

let addedDeviceAccessHook = false;
let enforcementRules;

function getGvlid() {
let gvlid;
const bidderCode = config.getCurrentBidder();
if (bidderCode) {
const bidder = adapterManager.getBidAdapter(bidderCode);
gvlid = bidder.getSpec().gvlid;
Copy link
Collaborator

Choose a reason for hiding this comment

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

What will happen here if gvlid is unspecified? Should it be undefined or 0?

Copy link
Collaborator

@harpere harpere Mar 27, 2020

Choose a reason for hiding this comment

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

looks like it would be undefined just like if there was no bidderCode. So it's consistent anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If no gvlid and gdpr rules are enforced then bidder or user id module will not have access.

} else {
utils.logWarn('Current bidder not found');
}
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
* @returns {boolean}
*/
function validateRules(rule, consentData, currentModule, gvlid) {
let isAllowed = false;
if (rule.enforcePurpose && rule.enforceVendor) {
if (includes(rule.vendorExceptions, currentModule) ||
bretg marked this conversation as resolved.
Show resolved Hide resolved
(utils.deepAccess(consentData, 'vendorData.purpose.consents.1') === true && utils.deepAccess(consentData, `vendorData.vendor.consents.${gvlid}`) === true)) {
isAllowed = true;
}
} else if (rule.enforcePurpose === false && rule.enforceVendor === true) {
if (includes(rule.vendorExceptions, currentModule) ||
(utils.deepAccess(consentData, `vendorData.vendor.consents.${gvlid}`) === true)) {
isAllowed = true;
}
} else if (rule.enforcePurpose === false && rule.enforceVendor === false) {
if ((includes(rule.vendorExceptions, currentModule) &&
(utils.deepAccess(consentData, 'vendorData.purpose.consents.1') === true && utils.deepAccess(consentData, `vendorData.vendor.consents${gvlid}`) === true)) ||
bretg marked this conversation as resolved.
Show resolved Hide resolved
!includes(rule.vendorExceptions, currentModule)) {
isAllowed = true;
}
}
return isAllowed;
jaiminpanchal27 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* This hook checks whether module has permission to access device or not. Device access include cookie and local storage
* @param {Function} fn reference to original function (used by hook logic)
* @param {Number=} gvlid gvlid of the module
* @param {string=} moduleName name of the module
*/
export function deviceAccessHook(fn, gvlid, moduleName) {
let result = {
hasEnforcementHook: true
}
if (!hasDeviceAccess()) {
utils.logWarn('Device access is disabled by Publisher');
result.valid = false;
return fn.call(this, result);
} else {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies && consentData.apiVersion === 2) {
if (!gvlid) {
gvlid = getGvlid();
}
const curModule = moduleName || config.getCurrentBidder();
const purpose1Rule = find(enforcementRules, hasPurpose1);
let isAllowed = validateRules(purpose1Rule, consentData, curModule, gvlid);
if (isAllowed) {
result.valid = true;
return fn.call(this, result);
} else {
utils.logWarn(`User denied Permission for Device access for ${curModule}`);
result.valid = false;
return fn.call(this, result);
}
} else {
utils.logInfo('GDPR enforcement only applies to CMP version 2');
jaiminpanchal27 marked this conversation as resolved.
Show resolved Hide resolved
result.valid = true;
return fn.call(this, result);
}
}
}

/**
* This hook checks if a bidder has consent for user sync or not
* @param {Function} fn reference to original function (used by hook logic)
* @param {...any} args args
*/
export function userSyncHook(fn, ...args) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies && 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}`);
}
} else {
utils.logWarn(`User sync not allowed for ${curBidder}`);
}
} else {
utils.logInfo('GDPR enforcement only applies to CMP version 2');
fn.call(this, ...args);
}
}

/**
* This hook checks if user id module is given consent or not
* @param {Function} fn reference to original function (used by hook logic)
* @param {Submodule[]} submodules Array of user id submodules
* @param {Object} consentData GDPR consent data
*/
export function userIdHook(fn, submodules, consentData) {
if (consentData && consentData.gdprApplies && consentData.apiVersion === 2) {
let userIdModules = submodules.map((submodule) => {
const gvlid = submodule.submodule.gvlid;
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`);
}
} 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);
} else {
utils.logInfo('GDPR enforcement only applies to CMP version 2');
fn.call(this, submodules, consentData);
}
}

const hasPurpose1 = (rule) => { return rule.purpose === purpose1 }

/**
* A configuration function that initializes some module variables, as well as add 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;
}

enforcementRules = rules;
const hasDefinedPurpose1 = find(enforcementRules, hasPurpose1);
if (hasDefinedPurpose1 && !addedDeviceAccessHook) {
addedDeviceAccessHook = true;
validateStorageEnforcement.before(deviceAccessHook);
registerSyncInner.before(userSyncHook);
// Using getHook as user id and gdprEnforcement are both optional modules. Using import will auto include the file in build
getHook('validateGdprEnforcement').before(userIdHook);
}
}

config.getConfig('consentManagement', config => setEnforcementConfig(config.consentManagement));
5 changes: 5 additions & 0 deletions modules/id5IdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const id5IdSubmodule = {
* @type {string}
*/
name: 'id5Id',
/**
* Vendor id of ID5
* @type {Number}
*/
gvlid: 131,
/**
* decode the stored id value for passing to bid requests
* @function decode
Expand Down
2 changes: 2 additions & 0 deletions modules/rubiconBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const FASTLANE_ENDPOINT = 'https://fastlane.rubiconproject.com/a/api/fast
export const VIDEO_ENDPOINT = 'https://prebid-server.rubiconproject.com/openrtb2/auction';
export const SYNC_ENDPOINT = 'https://eus.rubiconproject.com/usync.html';

const GVLID = 52;
const DIGITRUST_PROP_NAMES = {
FASTLANE: {
id: 'dt.id',
Expand Down Expand Up @@ -106,6 +107,7 @@ utils._each(sizeMap, (item, key) => sizeMap[item] = key);

export const spec = {
code: 'rubicon',
gvlid: GVLID,
supportedMediaTypes: [BANNER, VIDEO],
/**
* @param {object} bid
Expand Down
40 changes: 25 additions & 15 deletions modules/userId/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ import * as utils from '../../src/utils.js';
import {getGlobal} from '../../src/prebidGlobal.js';
import {gdprDataHandler} from '../../src/adapterManager.js';
import CONSTANTS from '../../src/constants.json';
import {module} from '../../src/hook.js';
import {module, hook} from '../../src/hook.js';
import {createEidsArray} from './eids.js';
import { newStorageManager } from '../../src/storageManager.js';

const MODULE_NAME = 'User ID';
const COOKIE = 'cookie';
const LOCAL_STORAGE = 'html5';
const DEFAULT_SYNC_DELAY = 500;
const NO_AUCTION_DELAY = 0;
export const coreStorage = newStorageManager({moduleName: 'userid', moduleType: 'prebid-module'});

/** @type {string[]} */
let validStorageTypes = [];
Expand Down Expand Up @@ -150,15 +152,15 @@ function setStoredValue(storage, value) {
const valueStr = utils.isPlainObject(value) ? JSON.stringify(value) : value;
const expiresStr = (new Date(Date.now() + (storage.expires * (60 * 60 * 24 * 1000)))).toUTCString();
if (storage.type === COOKIE) {
utils.setCookie(storage.name, valueStr, expiresStr, 'Lax');
coreStorage.setCookie(storage.name, valueStr, expiresStr, 'Lax');
if (typeof storage.refreshInSeconds === 'number') {
utils.setCookie(`${storage.name}_last`, new Date().toUTCString(), expiresStr);
coreStorage.setCookie(`${storage.name}_last`, new Date().toUTCString(), expiresStr);
}
} else if (storage.type === LOCAL_STORAGE) {
utils.setDataInLocalStorage(`${storage.name}_exp`, expiresStr);
utils.setDataInLocalStorage(storage.name, encodeURIComponent(valueStr));
localStorage.setItem(`${storage.name}_exp`, expiresStr);
localStorage.setItem(storage.name, encodeURIComponent(valueStr));
Copy link
Collaborator

Choose a reason for hiding this comment

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

are these localStorage references correct? Shouldn't they go to the coreStorage functions like the updates for the cookies above?

Copy link
Collaborator

Choose a reason for hiding this comment

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

+1

if (typeof storage.refreshInSeconds === 'number') {
utils.setDataInLocalStorage(`${storage.name}_last`, new Date().toUTCString());
localStorage.setItem(`${storage.name}_last`, new Date().toUTCString());
}
}
} catch (error) {
Expand All @@ -176,15 +178,15 @@ function getStoredValue(storage, key = undefined) {
let storedValue;
try {
if (storage.type === COOKIE) {
storedValue = utils.getCookie(storedKey);
storedValue = coreStorage.getCookie(storedKey);
} else if (storage.type === LOCAL_STORAGE) {
const storedValueExp = utils.getDataFromLocalStorage(`${storage.name}_exp`);
const storedValueExp = localStorage.getItem(`${storage.name}_exp`);
// empty string means no expiration set
if (storedValueExp === '') {
storedValue = utils.getDataFromLocalStorage(storedKey);
storedValue = localStorage.getItem(storedKey);
} else if (storedValueExp) {
if ((new Date(storedValueExp)).getTime() - Date.now() > 0) {
storedValue = decodeURIComponent(utils.getDataFromLocalStorage(storedKey));
storedValue = decodeURIComponent(localStorage.getItem(storedKey));
}
}
}
Expand Down Expand Up @@ -364,19 +366,27 @@ function getUserIds() {
return getCombinedSubmoduleIds(initializedSubmodules);
}

/**
* This hook returns updated list of submodules which are allowed to do get user id based on TCF 2 enforcement rules configured
*/
export const validateGdprEnforcement = hook('sync', function (submodules, consentData) {
return submodules;
}, 'validateGdprEnforcement');

/**
* @param {SubmoduleContainer[]} submodules
* @param {ConsentData} consentData
* @returns {SubmoduleContainer[]} initialized submodules
*/
function initSubmodules(submodules, consentData) {
// gdpr consent with purpose one is required, otherwise exit immediately
let userIdModules = validateGdprEnforcement(submodules, consentData);
if (!hasGDPRConsent(consentData)) {
utils.logWarn(`${MODULE_NAME} - gdpr permission not valid for local storage or cookies, exit module`);
return [];
}

return submodules.reduce((carry, submodule) => {
return userIdModules.reduce((carry, submodule) => {
// There are two submodule configuration types to handle: storage or value
// 1. storage: retrieve user id data from cookie/html storage or with the submodule's getId method
// 2. value: pass directly to bids
Expand Down Expand Up @@ -522,17 +532,17 @@ export function init(config) {

// list of browser enabled storage types
validStorageTypes = [
utils.localStorageIsEnabled() ? LOCAL_STORAGE : null,
utils.cookiesAreEnabled() ? COOKIE : null
coreStorage.localStorageIsEnabled() ? LOCAL_STORAGE : null,
coreStorage.cookiesAreEnabled() ? COOKIE : null
].filter(i => i !== null);

// exit immediately if opt out cookie or local storage keys exists.
if (validStorageTypes.indexOf(COOKIE) !== -1 && (utils.getCookie('_pbjs_id_optout') || utils.getCookie('_pubcid_optout'))) {
if (validStorageTypes.indexOf(COOKIE) !== -1 && (coreStorage.getCookie('_pbjs_id_optout') || coreStorage.getCookie('_pubcid_optout'))) {
utils.logInfo(`${MODULE_NAME} - opt-out cookie found, exit module`);
return;
}
// _pubcid_optout is checked for compatiblility with pubCommonId
if (validStorageTypes.indexOf(LOCAL_STORAGE) !== -1 && (utils.getDataFromLocalStorage('_pbjs_id_optout') || utils.getDataFromLocalStorage('_pubcid_optout'))) {
if (validStorageTypes.indexOf(LOCAL_STORAGE) !== -1 && (localStorage.getItem('_pbjs_id_optout') || localStorage.getItem('_pubcid_optout'))) {
utils.logInfo(`${MODULE_NAME} - opt-out localStorage found, exit module`);
return;
}
Expand Down
Loading