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
25 changes: 17 additions & 8 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ 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'
Copy link
Collaborator

Choose a reason for hiding this comment

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

https://github.com/prebid/Prebid.js/blob/master/modules/pubProvidedIdSystem.js also doesnt need consent but doesn't use device storage. Should it be on this list?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It does look like it should, but now I'm wondering, aren't all 3 doing the same thing? should we think about removing some at some point?

Copy link
Collaborator

Choose a reason for hiding this comment

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

the second one is definitely redundant and removing it is in the 8.0 proposal. PPID module is different, it allows a publisher to set any arbitrary id and also say what it is. It isn't very popular, but it would allow a publisher to say pass hash(login) or some other identifier that was quite different than the one used by sharedid / pubcommonid

]);

const TCF2 = {
'purpose1': { id: 1, name: 'storage' },
'purpose2': { id: 2, name: 'basicAds' },
Expand Down Expand Up @@ -123,9 +129,10 @@ function getGvlidForAnalyticsAdapter(code) {
* @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 +150,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,17 +167,19 @@ 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);
fn.call(this, isVendorless, gvlid, moduleName, result);
} else {
const consentData = gdprDataHandler.getConsentData();
if (consentData && consentData.gdprApplies) {
Expand All @@ -182,19 +191,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);
fn.call(this, isVendorless, gvlid, moduleName, result);
} else {
curModule && logWarn(`TCF2 denied device access for ${curModule}`);
result.valid = false;
storageBlocked.push(curModule);
fn.call(this, gvlid, moduleName, result);
fn.call(this, isVendorless, gvlid, moduleName, result);
}
} else {
result.valid = true;
fn.call(this, gvlid, moduleName, result);
fn.call(this, isVendorless, gvlid, moduleName, result);
}
}
}
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 @@ -1420,15 +1419,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 @@ -829,7 +826,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 @@ -880,7 +889,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 @@ -890,11 +899,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 @@ -905,11 +914,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 @@ -969,22 +1000,6 @@ export function init(config, {delay = delayFor} = {}) {
}
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
19 changes: 10 additions & 9 deletions src/storageManager.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';
import {config} from './config.js';

const moduleTypeWhiteList = ['core', 'prebid-module'];

export let storageCallbacks = [];

export const ALWAYS_ENFORCE = 'alwaysEnforceDeviceAccess';

/**
* Storage options
* @typedef {Object} storageOptions
Expand All @@ -33,8 +35,11 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
const storageAllowed = bidderSettings.get(bidderCode, 'storageAllowed');
return storageAllowed == null ? false : storageAllowed;
}

const isWhitelisted = moduleTypeWhiteList.includes(moduleType);

function isValid(cb) {
if (includes(moduleTypeWhiteList, moduleType)) {
if (isWhitelisted && !config.getConfig(ALWAYS_ENFORCE)) {
let result = {
valid: true
}
Expand All @@ -48,7 +53,7 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} =
let hookDetails = {
hasEnforcementHook: false
}
validateStorageEnforcement(gvlid, bidderCode || moduleName, hookDetails, function(result) {
validateStorageEnforcement(isWhitelisted, gvlid, bidderCode || moduleName, hookDetails, function(result) {
if (result && result.hasEnforcementHook) {
value = cb(result);
} else {
Expand Down Expand Up @@ -149,11 +154,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 +304,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
14 changes: 9 additions & 5 deletions src/userSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,16 @@ export function newUserSync(userSyncDependencies) {
return publicApi;
}

const browserSupportsCookies = !isSafariBrowser() && storage.cookiesAreEnabled();

export const userSync = newUserSync({
export const userSync = newUserSync(Object.defineProperties({
config: config.getConfig('userSync'),
browserSupportsCookies: browserSupportsCookies
});
}, {
browserSupportsCookies: {
get: function() {
// call storage lazily to give time for consent data to be available
return !isSafariBrowser() && storage.cookiesAreEnabled();
}
}
}));

/**
* @typedef {Object} UserSyncDependencies
Expand Down
Loading