-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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
Changes from 4 commits
a363913
c59776b
185e939
2efddad
227a4db
a4ff690
97c5861
506f958
a138650
e48fdf7
74e38b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -88,4 +88,4 @@ <h5>Div-1</h5> | |
</script> | ||
</div> | ||
</body> | ||
</html> | ||
</html> |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks like it would be There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 = []; | ||
|
@@ -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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are these There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
@@ -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)); | ||
} | ||
} | ||
} | ||
|
@@ -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 | ||
|
@@ -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; | ||
} | ||
|
There was a problem hiding this comment.
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.