Skip to content

Commit

Permalink
Core & multiple modules: strict purpose 1 consent option; do not requ…
Browse files Browse the repository at this point in the history
…ire vendor consent for "core" storage / ID modules (#8661)

* Allow sharedId to work without vendor consent

* Remove superfluous enforcement check from ixBidAdapter

* Make core storage respect device access rules

* respect storage access enforcement in userSync.js

* UserID: check whether storage is enabled only once consent can be enforced

* Add pubProvidedId

* GDPR enforcement: enforce consent when data is not available, but GDPR module is enabled

* Always enforce deviceAccess; move vendorless storage P1 enforcement behind `consentManagement.strictStorageEnforcement`
  • Loading branch information
dgirardi authored Jul 28, 2022
1 parent c94c8de commit 5812357
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 95 deletions.
54 changes: 40 additions & 14 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import {validateStorageEnforcement} from '../src/storageManager.js';
import * as events from '../src/events.js';
import CONSTANTS from '../src/constants.json';

// modules for which vendor consent is not needed (see https://github.com/prebid/Prebid.js/issues/8161)
const VENDORLESS_MODULES = new Set([
'sharedId',
'pubCommonId',
'pubProvidedId',
]);

export const STRICT_STORAGE_ENFORCEMENT = 'strictStorageEnforcement';

const TCF2 = {
'purpose1': { id: 1, name: 'storage' },
'purpose2': { id: 2, name: 'basicAds' },
Expand Down Expand Up @@ -44,6 +53,7 @@ const biddersBlocked = [];
const analyticsBlocked = [];

let hooksAdded = false;
let strictStorageEnforcement = false;

// Helps in stubbing these functions in unit tests.
export const internal = {
Expand Down Expand Up @@ -116,16 +126,29 @@ function getGvlidForAnalyticsAdapter(code) {
return adapterManager.getAnalyticsAdapter(code) && (adapterManager.getAnalyticsAdapter(code).gvlid || null);
}

export function shouldEnforce(consentData, purpose, name) {
if (consentData == null && gdprDataHandler.enabled) {
// there is no consent data, but the GDPR module has been installed and configured
// NOTE: this check is not foolproof, as when Prebid first loads, enforcement hooks have not been attached yet
// This piece of code would not run at all, and `gdprDataHandler.enabled` would be false, until the first
// `setConfig({consentManagement})`
logWarn(`Attempting operation that requires purpose ${purpose} consent while consent data is not available${name ? ` (module: ${name})` : ''}. Assuming no consent was given.`)
return true;
}
return consentData && consentData.gdprApplies;
}

/**
* 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
* @param vendorlessModule a predicate function that takes a module name, and returns true if the module does not need vendor consent
* @returns {boolean}
*/
export function validateRules(rule, consentData, currentModule, gvlId) {
export function validateRules(rule, consentData, currentModule, gvlId, vendorlessModule = VENDORLESS_MODULES.has.bind(VENDORLESS_MODULES)) {
const purposeId = TCF2[Object.keys(TCF2).filter(purposeName => TCF2[purposeName].name === rule.purpose)[0]].id;

// return 'true' if vendor present in 'vendorExceptions'
Expand All @@ -143,7 +166,7 @@ export function validateRules(rule, consentData, currentModule, gvlId) {
or the user has consented. Similar with vendors.
*/
const purposeAllowed = rule.enforcePurpose === false || purposeConsent === true;
const vendorAllowed = rule.enforceVendor === false || vendorConsent === true;
const vendorAllowed = vendorlessModule(currentModule) || rule.enforceVendor === false || vendorConsent === true;

/*
Few if any vendors should be declaring Legitimate Interest for Device Access (Purpose 1), but some are claiming
Expand All @@ -160,20 +183,24 @@ export function validateRules(rule, consentData, currentModule, gvlId) {
/**
* 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 isVendorless if true, do not require vendor consent (for e.g. core modules)
* @param {Number=} gvlid gvlid of the module
* @param {string=} moduleName name of the module
* @param result
*/
export function deviceAccessHook(fn, gvlid, moduleName, result) {
export function deviceAccessHook(fn, isVendorless, gvlid, moduleName, result, {validate = validateRules} = {}) {
result = Object.assign({}, {
hasEnforcementHook: true
});
if (!hasDeviceAccess()) {
logWarn('Device access is disabled by Publisher');
result.valid = false;
fn.call(this, gvlid, moduleName, result);
} else if (isVendorless && !strictStorageEnforcement) {
// for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set
result.valid = true;
} else {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (shouldEnforce(consentData, 1, moduleName)) {
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) {
Expand All @@ -182,21 +209,19 @@ export function deviceAccessHook(fn, gvlid, moduleName, result) {
gvlid = getGvlid(moduleName) || gvlid;
}
const curModule = moduleName || curBidder;
let isAllowed = validateRules(purpose1Rule, consentData, curModule, gvlid);
let isAllowed = validate(purpose1Rule, consentData, curModule, gvlid, isVendorless ? () => true : undefined);
if (isAllowed) {
result.valid = true;
fn.call(this, gvlid, moduleName, result);
} else {
curModule && logWarn(`TCF2 denied device access for ${curModule}`);
result.valid = false;
storageBlocked.push(curModule);
fn.call(this, gvlid, moduleName, result);
}
} else {
result.valid = true;
fn.call(this, gvlid, moduleName, result);
}
}
fn.call(this, isVendorless, gvlid, moduleName, result);
}

/**
Expand All @@ -206,8 +231,8 @@ export function deviceAccessHook(fn, gvlid, moduleName, result) {
*/
export function userSyncHook(fn, ...args) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
const curBidder = config.getCurrentBidder();
const curBidder = config.getCurrentBidder();
if (shouldEnforce(consentData, 1, curBidder)) {
const gvlid = getGvlid(curBidder);
let isAllowed = validateRules(purpose1Rule, consentData, curBidder, gvlid);
if (isAllowed) {
Expand All @@ -228,7 +253,7 @@ export function userSyncHook(fn, ...args) {
* @param {Object} consentData GDPR consent data
*/
export function userIdHook(fn, submodules, consentData) {
if (consentData && consentData.gdprApplies) {
if (shouldEnforce(consentData, 1, 'User ID')) {
let userIdModules = submodules.map((submodule) => {
const gvlid = getGvlid(submodule.submodule);
const moduleName = submodule.submodule.name;
Expand All @@ -255,7 +280,7 @@ export function userIdHook(fn, submodules, consentData) {
*/
export function makeBidRequestsHook(fn, adUnits, ...args) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (shouldEnforce(consentData, 2)) {
adUnits.forEach(adUnit => {
adUnit.bids = adUnit.bids.filter(bid => {
const currBidder = bid.bidder;
Expand Down Expand Up @@ -283,7 +308,7 @@ export function makeBidRequestsHook(fn, adUnits, ...args) {
*/
export function enableAnalyticsHook(fn, config) {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
if (shouldEnforce(consentData, 7, 'Analytics')) {
if (!isArray(config)) {
config = [config]
}
Expand Down Expand Up @@ -341,6 +366,7 @@ export function setEnforcementConfig(config) {
} else {
enforcementRules = rules;
}
strictStorageEnforcement = !!deepAccess(config, STRICT_STORAGE_ENFORCEMENT);

purpose1Rule = find(enforcementRules, hasPurpose1);
purpose2Rule = find(enforcementRules, hasPurpose2);
Expand Down
13 changes: 2 additions & 11 deletions modules/ixBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
deepClone,
deepSetValue,
getGptSlotInfoForAdUnitCode,
hasDeviceAccess,
inIframe,
isArray,
isEmpty,
Expand All @@ -20,7 +19,7 @@ import {
import {BANNER, VIDEO, NATIVE} from '../src/mediaTypes.js';
import {config} from '../src/config.js';
import CONSTANTS from '../src/constants.json';
import {getStorageManager, validateStorageEnforcement} from '../src/storageManager.js';
import {getStorageManager} from '../src/storageManager.js';
import * as events from '../src/events.js';
import {find} from '../src/polyfill.js';
import {registerBidder} from '../src/adapters/bidderFactory.js';
Expand Down Expand Up @@ -1474,15 +1473,7 @@ function storeErrorEventData(data) {
*/
function localStorageHandler(data) {
if (data.type === 'ERROR' && data.arguments && data.arguments[1] && data.arguments[1].bidder === BIDDER_CODE) {
const DEFAULT_ENFORCEMENT_SETTINGS = {
hasEnforcementHook: false,
valid: hasDeviceAccess()
};
validateStorageEnforcement(GLOBAL_VENDOR_ID, BIDDER_CODE, DEFAULT_ENFORCEMENT_SETTINGS, (permissions) => {
if (permissions.valid) {
storeErrorEventData(data);
}
});
storeErrorEventData(data);
}
}

Expand Down
2 changes: 1 addition & 1 deletion modules/pubCommonId.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as events from '../src/events.js';
import CONSTANTS from '../src/constants.json';
import { getStorageManager } from '../src/storageManager.js';

const storage = getStorageManager();
const storage = getStorageManager({moduleName: 'pubCommonId'});

const ID_NAME = '_pubcid';
const OPTOUT_NAME = '_pubcid_optout';
Expand Down
8 changes: 1 addition & 7 deletions modules/sharedIdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import {submodule} from '../src/hook.js';
import { coppaDataHandler } from '../src/adapterManager.js';
import {getStorageManager} from '../src/storageManager.js';

const GVLID = 887;
export const storage = getStorageManager({gvlid: GVLID, moduleName: 'pubCommonId'});
export const storage = getStorageManager({moduleName: 'pubCommonId'});
const COOKIE = 'cookie';
const LOCAL_STORAGE = 'html5';
const OPTOUT_NAME = '_pubcid_optout';
Expand Down Expand Up @@ -74,11 +73,6 @@ export const sharedIdSystemSubmodule = {
*/
name: 'sharedId',
aliasName: 'pubCommonId',
/**
* Vendor id of prebid
* @type {Number}
*/
gvlid: GVLID,

/**
* decode the stored id value for passing to bid requests
Expand Down
63 changes: 39 additions & 24 deletions modules/userId/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,6 @@ const CONSENT_DATA_COOKIE_STORAGE_CONFIG = {
export const PBJS_USER_ID_OPTOUT_NAME = '_pbjs_id_optout';
export const coreStorage = getCoreStorageManager('userid');

/** @type {string[]} */
let validStorageTypes = [];

/** @type {boolean} */
let addedUserIdHook = false;

Expand Down Expand Up @@ -834,7 +831,19 @@ function populateSubmoduleId(submodule, consentData, storedConsentData, forceRef
}

function initSubmodules(dest, submodules, consentData, forceRefresh = false) {
// gdpr consent with purpose one is required, otherwise exit immediately
if (!submodules.length) return []; // to simplify log messages from here on

// filter out submodules whose storage type is not enabled
// this needs to be done here (after consent data has loaded) so that enforcement may disable storage globally
const storageTypes = getActiveStorageTypes();
submodules = submodules.filter((submod) => !submod.config.storage || storageTypes.has(submod.config.storage.type));

if (!submodules.length) {
logWarn(`${MODULE_NAME} - no ID module is configured for one of the available storage types:`, Array.from(storageTypes))
return [];
}

// another consent check, this time each module is checked for consent with its own gvlid
let { userIdModules, hasValidated } = validateGdprEnforcement(submodules, consentData);
if (!hasValidated && !hasPurpose1Consent(consentData)) {
logWarn(`${MODULE_NAME} - gdpr permission not valid for local storage or cookies, exit module`);
Expand Down Expand Up @@ -885,7 +894,7 @@ function updateInitializedSubmodules(dest, submodule) {
* @param {string[]} activeStorageTypes
* @returns {SubmoduleConfig[]}
*/
function getValidSubmoduleConfigs(configRegistry, submoduleRegistry, activeStorageTypes) {
function getValidSubmoduleConfigs(configRegistry, submoduleRegistry) {
if (!Array.isArray(configRegistry)) {
return [];
}
Expand All @@ -895,11 +904,11 @@ function getValidSubmoduleConfigs(configRegistry, submoduleRegistry, activeStora
return carry;
}
// Validate storage config contains 'type' and 'name' properties with non-empty string values
// 'type' must be a value currently enabled in the browser
// 'type' must be one of html5, cookies
if (config.storage &&
!isEmptyStr(config.storage.type) &&
!isEmptyStr(config.storage.name) &&
activeStorageTypes.indexOf(config.storage.type) !== -1) {
ALL_STORAGE_TYPES.has(config.storage.type)) {
carry.push(config);
} else if (isPlainObject(config.value)) {
carry.push(config);
Expand All @@ -910,11 +919,33 @@ function getValidSubmoduleConfigs(configRegistry, submoduleRegistry, activeStora
}, []);
}

const ALL_STORAGE_TYPES = new Set([LOCAL_STORAGE, COOKIE]);

function getActiveStorageTypes() {
const storageTypes = [];
let disabled = false;
if (coreStorage.localStorageIsEnabled()) {
storageTypes.push(LOCAL_STORAGE);
if (coreStorage.getDataFromLocalStorage(PBJS_USER_ID_OPTOUT_NAME)) {
logInfo(`${MODULE_NAME} - opt-out localStorage found, storage disabled`);
disabled = true;
}
}
if (coreStorage.cookiesAreEnabled()) {
storageTypes.push(COOKIE);
if (coreStorage.getCookie(PBJS_USER_ID_OPTOUT_NAME)) {
logInfo(`${MODULE_NAME} - opt-out cookie found, storage disabled`);
disabled = true;
}
}
return new Set(disabled ? [] : storageTypes)
}

/**
* update submodules by validating against existing configs and storage types
*/
function updateSubmodules() {
const configs = getValidSubmoduleConfigs(configRegistry, submoduleRegistry, validStorageTypes);
const configs = getValidSubmoduleConfigs(configRegistry, submoduleRegistry);
if (!configs.length) {
return;
}
Expand Down Expand Up @@ -987,22 +1018,6 @@ export function init(config, {delay = GreedyPromise.timeout} = {}) {
}
submoduleRegistry = [];

// list of browser enabled storage types
validStorageTypes = [
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 && coreStorage.getCookie(PBJS_USER_ID_OPTOUT_NAME)) {
logInfo(`${MODULE_NAME} - opt-out cookie found, exit module`);
return;
}
if (validStorageTypes.indexOf(LOCAL_STORAGE) !== -1 && coreStorage.getDataFromLocalStorage(PBJS_USER_ID_OPTOUT_NAME)) {
logInfo(`${MODULE_NAME} - opt-out localStorage found, exit module`);
return;
}

// listen for config userSyncs to be set
configListener = config.getConfig('userSync', conf => {
// Note: support for 'usersync' was dropped as part of Prebid.js 4.0
Expand Down
21 changes: 7 additions & 14 deletions src/storageManager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {hook} from './hook.js';
import {hasDeviceAccess, checkCookieSupport, logError, logInfo, isPlainObject} from './utils.js';
import {includes} from './polyfill.js';
import {bidderSettings as defaultBidderSettings} from './bidderSettings.js';

const moduleTypeWhiteList = ['core', 'prebid-module'];
Expand Down Expand Up @@ -33,13 +32,11 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
const storageAllowed = bidderSettings.get(bidderCode, 'storageAllowed');
return storageAllowed == null ? false : storageAllowed;
}

const isVendorless = moduleTypeWhiteList.includes(moduleType);

function isValid(cb) {
if (includes(moduleTypeWhiteList, moduleType)) {
let result = {
valid: true
}
return cb(result);
} else if (!isBidderAllowed()) {
if (!isBidderAllowed()) {
logInfo(`bidderSettings denied access to device storage for bidder '${bidderCode}'`);
const result = {valid: false};
return cb(result);
Expand All @@ -48,7 +45,7 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
let hookDetails = {
hasEnforcementHook: false
}
validateStorageEnforcement(gvlid, bidderCode || moduleName, hookDetails, function(result) {
validateStorageEnforcement(isVendorless, gvlid, bidderCode || moduleName, hookDetails, function(result) {
if (result && result.hasEnforcementHook) {
value = cb(result);
} else {
Expand Down Expand Up @@ -149,11 +146,7 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
const cookiesAreEnabled = function (done) {
let cb = function (result) {
if (result && result.valid) {
if (checkCookieSupport()) {
return true;
}
window.document.cookie = 'prebid.cookieTest';
return window.document.cookie.indexOf('prebid.cookieTest') !== -1;
return checkCookieSupport();
}
return false;
}
Expand Down Expand Up @@ -303,7 +296,7 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
/**
* This hook validates the storage enforcement if gdprEnforcement module is included
*/
export const validateStorageEnforcement = hook('async', function(gvlid, moduleName, hookDetails, callback) {
export const validateStorageEnforcement = hook('async', function(isVendorless, gvlid, moduleName, hookDetails, callback) {
callback(hookDetails);
}, 'validateStorageEnforcement');

Expand Down
Loading

0 comments on commit 5812357

Please sign in to comment.