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

Core & multiple modules: strict purpose 1 consent option; do not require vendor consent for "core" storage / ID modules #8661

Merged
merged 10 commits into from
Jul 28, 2022
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 @@ -1471,15 +1470,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