From 5ece4bb0eeea95f09f0560cf6882af8491b30620 Mon Sep 17 00:00:00 2001 From: Serhii Holdun Date: Thu, 2 Jun 2022 19:30:16 +0300 Subject: [PATCH] Gdpr Enforcement module and sharedId/pubCommonId modules: vendor consent should not be enforced for first-party-id modules (#8448) * Fixed issue with gdprEnforcement module and sharedId/pubCommonId modules: vendor consent should not be enforced for first-party-id modules * addressed review comments * addressed review comments * added test to ensure device access is not allowed for vendorless modules in case purpose 1 consent isn't given * fixed issue with missing moduleType param Co-authored-by: Serhii Holdun --- modules/gdprEnforcement.js | 24 ++-- modules/ixBidAdapter.js | 3 +- modules/pubCommonId.js | 3 +- modules/sharedIdSystem.js | 16 +-- src/storageManager.js | 9 +- test/spec/modules/gdprEnforcement_spec.js | 153 ++++++++++++++-------- 6 files changed, 129 insertions(+), 79 deletions(-) diff --git a/modules/gdprEnforcement.js b/modules/gdprEnforcement.js index 161f530f202..4a54367a2a2 100644 --- a/modules/gdprEnforcement.js +++ b/modules/gdprEnforcement.js @@ -18,6 +18,8 @@ const TCF2 = { 'purpose7': { id: 7, name: 'measurement' } } +const VENDORLESS_MODULE_TYPES = ['fpid-module']; + /* These rules would be used if `consentManagement.gdpr.rules` is undefined by the publisher. */ @@ -123,9 +125,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 {string=} moduleType module type * @returns {boolean} */ -export function validateRules(rule, consentData, currentModule, gvlId) { +export function validateRules(rule, consentData, currentModule, gvlId, moduleType) { const purposeId = TCF2[Object.keys(TCF2).filter(purposeName => TCF2[purposeName].name === rule.purpose)[0]].id; // return 'true' if vendor present in 'vendorExceptions' @@ -138,12 +141,14 @@ export function validateRules(rule, consentData, currentModule, gvlId) { const vendorConsent = deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`); const liTransparency = deepAccess(consentData, `vendorData.purpose.legitimateInterests.${purposeId}`); + const vendorlessModule = includes(VENDORLESS_MODULE_TYPES, moduleType); + /* Since vendor exceptions have already been handled, the purpose as a whole is allowed if it's not being enforced or the user has consented. Similar with vendors. */ const purposeAllowed = rule.enforcePurpose === false || purposeConsent === true; - const vendorAllowed = rule.enforceVendor === false || vendorConsent === true; + const vendorAllowed = rule.enforceVendor === false || vendorConsent === true || vendorlessModule === true; /* Few if any vendors should be declaring Legitimate Interest for Device Access (Purpose 1), but some are claiming @@ -162,15 +167,16 @@ export function validateRules(rule, consentData, currentModule, gvlId) { * @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 + * @param {string=} moduleType module type */ -export function deviceAccessHook(fn, gvlid, moduleName, result) { +export function deviceAccessHook(fn, gvlid, moduleName, moduleType, result) { 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, gvlid, moduleName, moduleType, result); } else { const consentData = gdprDataHandler.getConsentData(); if (consentData && consentData.gdprApplies) { @@ -183,24 +189,24 @@ export function deviceAccessHook(fn, gvlid, moduleName, result) { gvlid = getGvlid(moduleName) || gvlid; } const curModule = moduleName || curBidder; - let isAllowed = validateRules(purpose1Rule, consentData, curModule, gvlid); + let isAllowed = validateRules(purpose1Rule, consentData, curModule, gvlid, moduleType); if (isAllowed) { result.valid = true; - fn.call(this, gvlid, moduleName, result); + fn.call(this, gvlid, moduleName, moduleType, 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, gvlid, moduleName, moduleType, result); } } else { // The module doesn't enforce TCF1.1 strings result.valid = true; - fn.call(this, gvlid, moduleName, result); + fn.call(this, gvlid, moduleName, moduleType, result); } } else { result.valid = true; - fn.call(this, gvlid, moduleName, result); + fn.call(this, gvlid, moduleName, moduleType, result); } } } diff --git a/modules/ixBidAdapter.js b/modules/ixBidAdapter.js index 1a9321b6852..1e3ad7b12d2 100644 --- a/modules/ixBidAdapter.js +++ b/modules/ixBidAdapter.js @@ -30,6 +30,7 @@ import {Renderer} from '../src/Renderer.js'; const BIDDER_CODE = 'ix'; const ALIAS_BIDDER_CODE = 'roundel'; const GLOBAL_VENDOR_ID = 10; +const MODULE_TYPE = 'bid-adapter'; const SECURE_BID_URL = 'https://htlb.casalemedia.com/cygnus'; const SUPPORTED_AD_TYPES = [BANNER, VIDEO]; const BANNER_ENDPOINT_VERSION = 7.2; @@ -1086,7 +1087,7 @@ function localStorageHandler(data) { hasEnforcementHook: false, valid: hasDeviceAccess() }; - validateStorageEnforcement(GLOBAL_VENDOR_ID, BIDDER_CODE, DEFAULT_ENFORCEMENT_SETTINGS, (permissions) => { + validateStorageEnforcement(GLOBAL_VENDOR_ID, BIDDER_CODE, MODULE_TYPE, DEFAULT_ENFORCEMENT_SETTINGS, (permissions) => { if (permissions.valid) { storeErrorEventData(data); } diff --git a/modules/pubCommonId.js b/modules/pubCommonId.js index faca59cce1c..e88cf7b9e7d 100644 --- a/modules/pubCommonId.js +++ b/modules/pubCommonId.js @@ -9,7 +9,8 @@ import * as events from '../src/events.js'; import CONSTANTS from '../src/constants.json'; import { getStorageManager } from '../src/storageManager.js'; -const storage = getStorageManager(); +const MODULE_TYPE = 'fpid-module'; +const storage = getStorageManager({moduleType: MODULE_TYPE}); const ID_NAME = '_pubcid'; const OPTOUT_NAME = '_pubcid_optout'; diff --git a/modules/sharedIdSystem.js b/modules/sharedIdSystem.js index 656b62815c7..8ed5699ff22 100644 --- a/modules/sharedIdSystem.js +++ b/modules/sharedIdSystem.js @@ -5,13 +5,13 @@ * @requires module:modules/userId */ -import { parseUrl, buildUrl, triggerPixel, logInfo, hasDeviceAccess, generateUUID } from '../src/utils.js'; +import {buildUrl, generateUUID, hasDeviceAccess, logInfo, parseUrl, triggerPixel} from '../src/utils.js'; import {submodule} from '../src/hook.js'; -import { coppaDataHandler } from '../src/adapterManager.js'; +import {coppaDataHandler} from '../src/adapterManager.js'; import {getStorageManager} from '../src/storageManager.js'; -const GVLID = 887; -export const storage = getStorageManager({gvlid: GVLID, moduleName: 'pubCommonId'}); +const MODULE_TYPE = 'fpid-module'; +export const storage = getStorageManager({moduleName: 'pubCommonId', moduleType: MODULE_TYPE}); const COOKIE = 'cookie'; const LOCAL_STORAGE = 'html5'; const OPTOUT_NAME = '_pubcid_optout'; @@ -74,11 +74,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 @@ -93,8 +88,7 @@ export const sharedIdSystemSubmodule = { return undefined; } logInfo(' Decoded value PubCommonId ' + value); - const idObj = {'pubcid': value}; - return idObj; + return {'pubcid': value}; }, /** * performs action to obtain id diff --git a/src/storageManager.js b/src/storageManager.js index 30e8d4c8abb..f13a462eae7 100644 --- a/src/storageManager.js +++ b/src/storageManager.js @@ -48,7 +48,7 @@ export function newStorageManager({gvlid, moduleName, bidderCode, moduleType} = let hookDetails = { hasEnforcementHook: false } - validateStorageEnforcement(gvlid, bidderCode || moduleName, hookDetails, function(result) { + validateStorageEnforcement(gvlid, bidderCode || moduleName, moduleType, hookDetails, function(result) { if (result && result.hasEnforcementHook) { value = cb(result); } else { @@ -303,7 +303,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(gvlid, moduleName, moduleType, hookDetails, callback) { callback(hookDetails); }, 'validateStorageEnforcement'); @@ -322,12 +322,13 @@ export function getCoreStorageManager(moduleName) { * @param {Number=} gvlid? Vendor id - required for proper GDPR integration * @param {string=} bidderCode? - required for bid adapters * @param {string=} moduleName? module name + * @param {string=} moduleType? module type */ -export function getStorageManager({gvlid, moduleName, bidderCode} = {}) { +export function getStorageManager({gvlid, moduleName, bidderCode, moduleType} = {}) { if (arguments.length > 1 || (arguments.length > 0 && !isPlainObject(arguments[0]))) { throw new Error('Invalid invocation for getStorageManager') } - return newStorageManager({gvlid, moduleName, bidderCode}); + return newStorageManager({gvlid, moduleName, bidderCode, moduleType}); } export function resetData() { diff --git a/test/spec/modules/gdprEnforcement_spec.js b/test/spec/modules/gdprEnforcement_spec.js index a78f5ac948e..e74b4e82263 100644 --- a/test/spec/modules/gdprEnforcement_spec.js +++ b/test/spec/modules/gdprEnforcement_spec.js @@ -1,21 +1,21 @@ import { deviceAccessHook, - setEnforcementConfig, - userSyncHook, - userIdHook, - makeBidRequestsHook, - validateRules, + enableAnalyticsHook, enforcementRules, + getGvlid, + internal, + makeBidRequestsHook, purpose1Rule, purpose2Rule, - enableAnalyticsHook, - getGvlid, - internal + setEnforcementConfig, + userIdHook, + userSyncHook, + validateRules } from 'modules/gdprEnforcement.js'; -import { config } from 'src/config.js'; -import adapterManager, { gdprDataHandler } from 'src/adapterManager.js'; +import {config} from 'src/config.js'; +import adapterManager, {gdprDataHandler} from 'src/adapterManager.js'; import * as utils from 'src/utils.js'; -import { validateStorageEnforcement } from 'src/storageManager.js'; +import {validateStorageEnforcement} from 'src/storageManager.js'; import * as events from 'src/events.js'; describe('gdpr enforcement', function () { @@ -98,9 +98,9 @@ describe('gdpr enforcement', function () { }; after(function () { - validateStorageEnforcement.getHooks({ hook: deviceAccessHook }).remove(); + validateStorageEnforcement.getHooks({hook: deviceAccessHook}).remove(); $$PREBID_GLOBAL$$.requestBids.getHooks().remove(); - adapterManager.makeBidRequests.getHooks({ hook: makeBidRequestsHook }).remove(); + adapterManager.makeBidRequests.getHooks({hook: makeBidRequestsHook}).remove(); }) describe('deviceAccessHook', function () { @@ -149,7 +149,7 @@ describe('gdpr enforcement', function () { hasEnforcementHook: true, valid: false } - sinon.assert.calledWith(nextFnSpy, undefined, undefined, result); + sinon.assert.calledWith(nextFnSpy, undefined, undefined, undefined, result); }); it('should only check for consent for vendor exceptions when enforcePurpose and enforceVendor are false', function () { @@ -199,6 +199,53 @@ describe('gdpr enforcement', function () { expect(logWarnSpy.callCount).to.equal(1); }); + it('should allow device access when enforce vendor but module is vendorless ', function () { + adapterManagerStub.withArgs('pubCommonId').returns(getBidderSpec(1)); + setEnforcementConfig({ + gdpr: { + rules: [{ + purpose: 'storage', + enforcePurpose: true, + enforceVendor: true, + }] + } + }); + let consentData = {} + consentData.vendorData = staticConfig.consentData.getTCData; + consentData.gdprApplies = true; + consentData.apiVersion = 2; + gdprDataHandlerStub.returns(consentData); + + deviceAccessHook(nextFnSpy, 1, 'pubCommonId', 'fpid-module'); + expect(logWarnSpy.callCount).to.equal(0); + }); + + it('should not allow device access if enforce vendor, module is vendorless, but there is no consent for purpose 1', function () { + adapterManagerStub.withArgs('pubCommonId').returns(getBidderSpec(1)); + setEnforcementConfig({ + gdpr: { + rules: [{ + purpose: 'storage', + enforcePurpose: true, + enforceVendor: true, + }] + } + }); + + let consentData = {} + // set consent for purpose 1 to false + const newConsentData = utils.deepClone(staticConfig); + newConsentData.consentData.getTCData.purpose.consents['1'] = false; + consentData.vendorData = newConsentData.consentData.getTCData; + consentData.apiVersion = 2; + consentData.gdprApplies = true; + + gdprDataHandlerStub.returns(consentData); + + deviceAccessHook(nextFnSpy, 1, 'pubCommonId', 'fpid-module'); + expect(logWarnSpy.callCount).to.equal(1); + }); + it('should allow device access when gdprApplies is false and hasDeviceAccess flag is true', function () { adapterManagerStub.withArgs('appnexus').returns(getBidderSpec(1)); setEnforcementConfig({ @@ -223,10 +270,10 @@ describe('gdpr enforcement', function () { hasEnforcementHook: true, valid: true } - sinon.assert.calledWith(nextFnSpy, 1, 'appnexus', result); + sinon.assert.calledWith(nextFnSpy, 1, 'appnexus', undefined, result); }); - it('should use gvlMapping set by publisher', function() { + it('should use gvlMapping set by publisher', function () { config.setConfig({ 'gvlMapping': { 'appnexus': 4 @@ -254,11 +301,11 @@ describe('gdpr enforcement', function () { hasEnforcementHook: true, valid: true } - sinon.assert.calledWith(nextFnSpy, 4, 'appnexus', result); + sinon.assert.calledWith(nextFnSpy, 4, 'appnexus', undefined, result); config.resetConfig(); }); - it('should use gvl id of alias and not of parent', function() { + it('should use gvl id of alias and not of parent', function () { let curBidderStub = sinon.stub(config, 'getCurrentBidder'); curBidderStub.returns('appnexus-alias'); adapterManager.aliasBidAdapter('appnexus', 'appnexus-alias'); @@ -289,7 +336,7 @@ describe('gdpr enforcement', function () { hasEnforcementHook: true, valid: true } - sinon.assert.calledWith(nextFnSpy, 4, 'appnexus', result); + sinon.assert.calledWith(nextFnSpy, 4, 'appnexus', undefined, result); config.resetConfig(); curBidderStub.restore(); }); @@ -470,7 +517,7 @@ describe('gdpr enforcement', function () { const args = nextFnSpy.getCalls()[0].args; expect(args[1].hasValidated).to.be.true; expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, submodules, { ...consentData, hasValidated: true }); + sinon.assert.calledWith(nextFnSpy, submodules, {...consentData, hasValidated: true}); }); it('should allow userId module if gdpr not in scope', function () { @@ -524,7 +571,7 @@ describe('gdpr enforcement', function () { name: 'sampleUserId' } }] - sinon.assert.calledWith(nextFnSpy, expectedSubmodules, { ...consentData, hasValidated: true }); + sinon.assert.calledWith(nextFnSpy, expectedSubmodules, {...consentData, hasValidated: true}); }); }); @@ -583,17 +630,17 @@ describe('gdpr enforcement', function () { gdprDataHandlerStub.returns(consentData); adapterManagerStub.withArgs('bidder_1').returns({ getSpec: function () { - return { 'gvlid': 4 } + return {'gvlid': 4} } }); adapterManagerStub.withArgs('bidder_2').returns({ getSpec: function () { - return { 'gvlid': 5 } + return {'gvlid': 5} } }); adapterManagerStub.withArgs('bidder_3').returns({ getSpec: function () { - return { 'gvlid': undefined } + return {'gvlid': undefined} } }); makeBidRequestsHook(nextFnSpy, MOCK_AD_UNITS, []); @@ -604,20 +651,20 @@ describe('gdpr enforcement', function () { code: 'ad-unit-1', mediaTypes: {}, bids: [ - sinon.match({ bidder: 'bidder_1' }), - sinon.match({ bidder: 'bidder_2' }) + sinon.match({bidder: 'bidder_1'}), + sinon.match({bidder: 'bidder_2'}) ] }, { code: 'ad-unit-2', mediaTypes: {}, bids: [ - sinon.match({ bidder: 'bidder_2' }), - sinon.match({ bidder: 'bidder_3' }) // should be allowed even though it's doesn't have a gvlId because liTransparency is established. + sinon.match({bidder: 'bidder_2'}), + sinon.match({bidder: 'bidder_3'}) // should be allowed even though it's doesn't have a gvlId because liTransparency is established. ] }], []); }); - it('should block bidder which does not have consent and allow bidder which has consent (liTransparency is NOT established)', function() { + it('should block bidder which does not have consent and allow bidder which has consent (liTransparency is NOT established)', function () { setEnforcementConfig({ gdpr: { rules: [{ @@ -641,17 +688,17 @@ describe('gdpr enforcement', function () { gdprDataHandlerStub.returns(consentData); adapterManagerStub.withArgs('bidder_1').returns({ getSpec: function () { - return { 'gvlid': 4 } + return {'gvlid': 4} } }); adapterManagerStub.withArgs('bidder_2').returns({ getSpec: function () { - return { 'gvlid': 5 } + return {'gvlid': 5} } }); adapterManagerStub.withArgs('bidder_3').returns({ getSpec: function () { - return { 'gvlid': undefined } + return {'gvlid': undefined} } }); @@ -663,13 +710,13 @@ describe('gdpr enforcement', function () { code: 'ad-unit-1', mediaTypes: {}, bids: [ - sinon.match({ bidder: 'bidder_1' }), // 'bidder_2' is not present because it doesn't have vendorConsent + sinon.match({bidder: 'bidder_1'}), // 'bidder_2' is not present because it doesn't have vendorConsent ] }, { code: 'ad-unit-2', mediaTypes: {}, bids: [ - sinon.match({ bidder: 'bidder_3' }), // 'bidder_3' is allowed despite gvlId being undefined because it's part of vendorExceptions + sinon.match({bidder: 'bidder_3'}), // 'bidder_3' is allowed despite gvlId being undefined because it's part of vendorExceptions ] }], []); @@ -727,12 +774,12 @@ describe('gdpr enforcement', function () { nextFnSpy = sandbox.spy(); }); - afterEach(function() { + afterEach(function () { config.resetConfig(); sandbox.restore(); }); - it('should block analytics adapter which does not have consent and allow the one(s) which have consent', function() { + it('should block analytics adapter which does not have consent and allow the one(s) which have consent', function () { setEnforcementConfig({ gdpr: { rules: [{ @@ -750,9 +797,9 @@ describe('gdpr enforcement', function () { consentData.gdprApplies = true; gdprDataHandlerStub.returns(consentData); - adapterManagerStub.withArgs('analyticsAdapter_A').returns({ gvlid: 3 }); - adapterManagerStub.withArgs('analyticsAdapter_B').returns({ gvlid: 5 }); - adapterManagerStub.withArgs('analyticsAdapter_C').returns({ gvlid: 1 }); + adapterManagerStub.withArgs('analyticsAdapter_A').returns({gvlid: 3}); + adapterManagerStub.withArgs('analyticsAdapter_B').returns({gvlid: 5}); + adapterManagerStub.withArgs('analyticsAdapter_C').returns({gvlid: 1}); enableAnalyticsHook(nextFnSpy, MOCK_ANALYTICS_ADAPTER_CONFIG); @@ -1025,7 +1072,7 @@ describe('gdpr enforcement', function () { expect(purpose2Rule).to.deep.equal(purpose2RuleDefinedInConfig); }); - it('should use the "rules" defined in config if a definition found', function() { + it('should use the "rules" defined in config if a definition found', function () { const rules = [{ purpose: 'storage', enforcePurpose: false, @@ -1035,23 +1082,23 @@ describe('gdpr enforcement', function () { enforcePurpose: false, enforceVendor: false }] - setEnforcementConfig({gdpr: { rules }}); + setEnforcementConfig({gdpr: {rules}}); expect(enforcementRules).to.deep.equal(rules); }); }); - describe('TCF2FinalResults', function() { + describe('TCF2FinalResults', function () { let sandbox; - beforeEach(function() { + beforeEach(function () { sandbox = sinon.createSandbox(); sandbox.spy(events, 'emit'); }); - afterEach(function() { + afterEach(function () { config.resetConfig(); sandbox.restore(); }); - it('should emit TCF2 enforcement data on auction end', function() { + it('should emit TCF2 enforcement data on auction end', function () { const rules = [{ purpose: 'storage', enforcePurpose: false, @@ -1061,7 +1108,7 @@ describe('gdpr enforcement', function () { enforcePurpose: false, enforceVendor: false }] - setEnforcementConfig({gdpr: { rules }}); + setEnforcementConfig({gdpr: {rules}}); events.emit('auctionEnd', {}) @@ -1070,28 +1117,28 @@ describe('gdpr enforcement', function () { }) }); - describe('getGvlid', function() { + describe('getGvlid', function () { let sandbox; let getGvlidForBidAdapterStub; let getGvlidForUserIdModuleStub; let getGvlidForAnalyticsAdapterStub; - beforeEach(function() { + beforeEach(function () { sandbox = sinon.createSandbox(); getGvlidForBidAdapterStub = sandbox.stub(internal, 'getGvlidForBidAdapter'); getGvlidForUserIdModuleStub = sandbox.stub(internal, 'getGvlidForUserIdModule'); getGvlidForAnalyticsAdapterStub = sandbox.stub(internal, 'getGvlidForAnalyticsAdapter'); }); - afterEach(function() { + afterEach(function () { sandbox.restore(); config.resetConfig(); }); - it('should return "null" if called without passing any argument', function() { + it('should return "null" if called without passing any argument', function () { const gvlid = getGvlid(); expect(gvlid).to.equal(null); }); - it('should return "null" if GVL ID is not defined for any of these modules: Bid adapter, UserId submodule and Analytics adapter', function() { + it('should return "null" if GVL ID is not defined for any of these modules: Bid adapter, UserId submodule and Analytics adapter', function () { getGvlidForBidAdapterStub.withArgs('moduleA').returns(null); getGvlidForUserIdModuleStub.withArgs('moduleA').returns(null); getGvlidForAnalyticsAdapterStub.withArgs('moduleA').returns(null); @@ -1100,7 +1147,7 @@ describe('gdpr enforcement', function () { expect(gvlid).to.equal(null); }); - it('should return the GVL ID from gvlMapping if it is defined in setConfig', function() { + it('should return the GVL ID from gvlMapping if it is defined in setConfig', function () { config.setConfig({ gvlMapping: { moduleA: 1 @@ -1114,7 +1161,7 @@ describe('gdpr enforcement', function () { expect(gvlid).to.equal(1); }); - it('should return the GVL ID by calling getGvlidForBidAdapter -> getGvlidForUserIdModule -> getGvlidForAnalyticsAdapter in sequence', function() { + it('should return the GVL ID by calling getGvlidForBidAdapter -> getGvlidForUserIdModule -> getGvlidForAnalyticsAdapter in sequence', function () { getGvlidForBidAdapterStub.withArgs('moduleA').returns(null); getGvlidForUserIdModuleStub.withArgs('moduleA').returns(null); getGvlidForAnalyticsAdapterStub.withArgs('moduleA').returns(7);