Skip to content

Commit

Permalink
gdprEnforcement: transmitEids and transmitPreciseGeo activity controls (
Browse files Browse the repository at this point in the history
#10435)

* transmitEids GDPR rule

* transmitPreciseGeo as gdpr special feature 1

* fix capitalization eidsRequireP4consent -> eidsRequireP4Consent
  • Loading branch information
dgirardi authored Sep 21, 2023
1 parent ab35867 commit 549a85c
Show file tree
Hide file tree
Showing 3 changed files with 401 additions and 104 deletions.
216 changes: 124 additions & 92 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import {deepAccess, logError, logWarn} from '../src/utils.js';
import {config} from '../src/config.js';
import adapterManager, {gdprDataHandler} from '../src/adapterManager.js';
import {find} from '../src/polyfill.js';
import * as events from '../src/events.js';
import CONSTANTS from '../src/constants.json';
import {GDPR_GVLIDS, VENDORLESS_GVLID} from '../src/consentHandler.js';
Expand All @@ -27,44 +26,62 @@ import {
ACTIVITY_ENRICH_EIDS, ACTIVITY_ENRICH_UFPD,
ACTIVITY_FETCH_BIDS,
ACTIVITY_REPORT_ANALYTICS,
ACTIVITY_SYNC_USER, ACTIVITY_TRANSMIT_UFPD
ACTIVITY_SYNC_USER, ACTIVITY_TRANSMIT_EIDS, ACTIVITY_TRANSMIT_PRECISE_GEO, ACTIVITY_TRANSMIT_UFPD
} from '../src/activities/activities.js';

export const STRICT_STORAGE_ENFORCEMENT = 'strictStorageEnforcement';

const TCF2 = {
purpose1: {id: 1, name: 'storage'},
purpose2: {id: 2, name: 'basicAds'},
purpose4: {id: 4, name: 'personalizedAds'},
purpose7: {id: 7, name: 'measurement'},
export const ACTIVE_RULES = {
purpose: {},
feature: {}
};

/*
These rules would be used if `consentManagement.gdpr.rules` is undefined by the publisher.
*/
const DEFAULT_RULES = [{
purpose: 'storage',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
}, {
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
}];

export let purpose1Rule;
export let purpose2Rule;
export let purpose4Rule;
export let purpose7Rule;

export let enforcementRules;
const CONSENT_PATHS = {
purpose: 'purpose.consents',
feature: 'specialFeatureOptins'
};

const CONFIGURABLE_RULES = {
storage: {
type: 'purpose',
default: {
purpose: 'storage',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
},
id: 1,
},
basicAds: {
type: 'purpose',
id: 2,
default: {
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: []
}
},
personalizedAds: {
type: 'purpose',
id: 4,
},
measurement: {
type: 'purpose',
id: 7,
},
transmitPreciseGeo: {
type: 'feature',
id: 1,
},
};

const storageBlocked = new Set();
const biddersBlocked = new Set();
const analyticsBlocked = new Set();
const ufpdBlocked = new Set();
const eidsBlocked = new Set();
const geoBlocked = new Set();

let hooksAdded = false;
let strictStorageEnforcement = false;
Expand All @@ -79,6 +96,9 @@ const GVLID_LOOKUP_PRIORITY = [
const RULE_NAME = 'TCF2';
const RULE_HANDLES = [];

// in JS we do not have access to the GVL; assume that everyone declares legitimate interest for basic ads
const LI_PURPOSES = [2];

/**
* Retrieve a module's GVL ID.
*/
Expand Down Expand Up @@ -143,6 +163,16 @@ export function shouldEnforce(consentData, purpose, name) {
return consentData && consentData.gdprApplies;
}

function getConsent(consentData, type, id, gvlId) {
let purpose = !!deepAccess(consentData, `vendorData.${CONSENT_PATHS[type]}.${id}`);
let vendor = !!deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`);
if (type === 'purpose' && LI_PURPOSES.includes(id)) {
purpose ||= !!deepAccess(consentData, `vendorData.purpose.legitimateInterests.${id}`);
vendor ||= !!deepAccess(consentData, `vendorData.vendor.legitimateInterests.${gvlId}`);
}
return {purpose, vendor};
}

/**
* 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.
Expand All @@ -153,42 +183,24 @@ export function shouldEnforce(consentData, purpose, name) {
* @returns {boolean}
*/
export function validateRules(rule, consentData, currentModule, gvlId) {
const purposeId = TCF2[Object.keys(TCF2).filter(purposeName => TCF2[purposeName].name === rule.purpose)[0]].id;
const ruleOptions = CONFIGURABLE_RULES[rule.purpose];

// return 'true' if vendor present in 'vendorExceptions'
if ((rule.vendorExceptions || []).includes(currentModule)) {
return true;
}
const vendorConsentRequred = rule.enforceVendor && !((gvlId === VENDORLESS_GVLID || (rule.softVendorExceptions || []).includes(currentModule)));

let purposeAllowed = !rule.enforcePurpose || !!deepAccess(consentData, `vendorData.purpose.consents.${purposeId}`);
let vendorAllowed = !vendorConsentRequred || !!deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`);

if (purposeId === 2) {
purposeAllowed ||= !!deepAccess(consentData, `vendorData.purpose.legitimateInterests.${purposeId}`);
vendorAllowed ||= !!deepAccess(consentData, `vendorData.vendor.legitimateInterests.${gvlId}`);
}

return purposeAllowed && vendorAllowed;
const {purpose, vendor} = getConsent(consentData, ruleOptions.type, ruleOptions.id, gvlId);
return (!rule.enforcePurpose || purpose) && (!vendorConsentRequred || vendor);
}

/**
* all activity rules follow the same structure:
* if GDPR is in scope, check configuration for a particular purpose, and if that enables enforcement,
* check against consent data for that purpose and vendor
*
* @param purposeNo TCF purpose number to check for this activity
* @param getEnforcementRule getter for gdprEnforcement rule definition to use
* @param blocked optional set to use for collecting denied vendors
* @param gvlidFallback optional factory function for a gvlid falllback function
*/
function gdprRule(purposeNo, getEnforcementRule, blocked = null, gvlidFallback = () => null) {
function gdprRule(purposeNo, checkConsent, blocked = null, gvlidFallback = () => null) {
return function (params) {
const consentData = gdprDataHandler.getConsentData();
const modName = params[ACTIVITY_PARAM_COMPONENT_NAME];
if (shouldEnforce(consentData, purposeNo, modName)) {
const gvlid = getGvlid(params[ACTIVITY_PARAM_COMPONENT_TYPE], modName, gvlidFallback(params));
let allow = !!validateRules(getEnforcementRule(), consentData, modName, gvlid);
let allow = !!checkConsent(consentData, modName, gvlid);
if (!allow) {
blocked && blocked.add(modName);
return {allow};
Expand All @@ -197,32 +209,62 @@ function gdprRule(purposeNo, getEnforcementRule, blocked = null, gvlidFallback =
};
}

export const accessDeviceRule = ((rule) => {
return function (params) {
// for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set
if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_PREBID && !strictStorageEnforcement) return;
return rule(params);
};
})(gdprRule(1, () => purpose1Rule, storageBlocked));

export const syncUserRule = gdprRule(1, () => purpose1Rule, storageBlocked);
export const enrichEidsRule = gdprRule(1, () => purpose1Rule, storageBlocked);
function singlePurposeGdprRule(purposeNo, blocked = null, gvlidFallback = () => null) {
return gdprRule(purposeNo, (cd, modName, gvlid) => !!validateRules(ACTIVE_RULES.purpose[purposeNo], cd, modName, gvlid), blocked, gvlidFallback);
}

export const fetchBidsRule = ((rule) => {
function exceptPrebidModules(ruleFn) {
return function (params) {
if (params[ACTIVITY_PARAM_COMPONENT_TYPE] !== MODULE_TYPE_BIDDER) {
if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_PREBID) {
// TODO: this special case is for the PBS adapter (componentType is 'prebid')
// we should check for generic purpose 2 consent & vendor consent based on the PBS vendor's GVL ID;
// that is, however, a breaking change and skipped for now
return;
}
return ruleFn(params);
};
}

export const accessDeviceRule = ((rule) => {
return function (params) {
// for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set
if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_PREBID && !strictStorageEnforcement) return;
return rule(params);
};
})(gdprRule(2, () => purpose2Rule, biddersBlocked));
})(singlePurposeGdprRule(1, storageBlocked));

export const syncUserRule = singlePurposeGdprRule(1, storageBlocked);
export const enrichEidsRule = singlePurposeGdprRule(1, storageBlocked);
export const fetchBidsRule = exceptPrebidModules(singlePurposeGdprRule(2, biddersBlocked));
export const reportAnalyticsRule = singlePurposeGdprRule(7, analyticsBlocked, (params) => getGvlidFromAnalyticsAdapter(params[ACTIVITY_PARAM_COMPONENT_NAME], params[ACTIVITY_PARAM_ANL_CONFIG]));
export const ufpdRule = singlePurposeGdprRule(4, ufpdBlocked);

export const transmitEidsRule = exceptPrebidModules((() => {
// Transmit EID special case:
// by default, legal basis or vendor exceptions for any purpose between 2 and 10
// (but disregarding enforcePurpose and enforceVendor config) is enough to allow EIDs through
function check2to10Consent(consentData, modName, gvlId) {
for (let pno = 2; pno <= 10; pno++) {
if (ACTIVE_RULES.purpose[pno]?.vendorExceptions?.includes(modName)) {
return true;
}
const {purpose, vendor} = getConsent(consentData, 'purpose', pno, gvlId);
if (purpose && (vendor || ACTIVE_RULES.purpose[pno]?.softVendorExceptions?.includes(modName))) {
return true;
}
}
return false;
}

export const reportAnalyticsRule = gdprRule(7, () => purpose7Rule, analyticsBlocked, (params) => getGvlidFromAnalyticsAdapter(params[ACTIVITY_PARAM_COMPONENT_NAME], params[ACTIVITY_PARAM_ANL_CONFIG]));
const defaultBehavior = gdprRule('2-10', check2to10Consent, eidsBlocked);
const p4Behavior = singlePurposeGdprRule(4, eidsBlocked);
return function () {
const fn = ACTIVE_RULES.purpose[4]?.eidsRequireP4Consent ? p4Behavior : defaultBehavior;
return fn.apply(this, arguments);
};
})());

export const ufpdRule = gdprRule(4, () => purpose4Rule, ufpdBlocked);
export const transmitPreciseGeoRule = gdprRule('Special Feature 1', (cd, modName, gvlId) => validateRules(ACTIVE_RULES.feature[1], cd, modName, gvlId), geoBlocked);

/**
* Compiles the TCF2.0 enforcement results into an object, which is emitted as an event payload to "tcf2Enforcement" event.
Expand All @@ -237,65 +279,55 @@ function emitTCF2FinalResults() {
biddersBlocked: formatSet(biddersBlocked),
analyticsBlocked: formatSet(analyticsBlocked),
ufpdBlocked: formatSet(ufpdBlocked),
eidsBlocked: formatSet(eidsBlocked),
geoBlocked: formatSet(geoBlocked)
};

events.emit(CONSTANTS.EVENTS.TCF2_ENFORCEMENT, tcf2FinalResults);
[storageBlocked, biddersBlocked, analyticsBlocked, ufpdBlocked].forEach(el => el.clear());
[storageBlocked, biddersBlocked, analyticsBlocked, ufpdBlocked, eidsBlocked, geoBlocked].forEach(el => el.clear());
}

events.on(CONSTANTS.EVENTS.AUCTION_END, emitTCF2FinalResults);

function hasPurpose(purposeNo) {
const pname = TCF2[`purpose${purposeNo}`].name;
return (rule) => rule.purpose === pname;
}

/**
* A configuration function that initializes some module variables, as well as adds hooks
* @param {Object} config - GDPR enforcement config object
*/
export function setEnforcementConfig(config) {
const rules = deepAccess(config, 'gdpr.rules');
let rules = deepAccess(config, 'gdpr.rules');
if (!rules) {
logWarn('TCF2: enforcing P1 and P2 by default');
enforcementRules = DEFAULT_RULES;
} else {
enforcementRules = rules;
}
rules = Object.fromEntries((rules || []).map(r => [r.purpose, r]));
strictStorageEnforcement = !!deepAccess(config, STRICT_STORAGE_ENFORCEMENT);

purpose1Rule = find(enforcementRules, hasPurpose(1));
purpose2Rule = find(enforcementRules, hasPurpose(2));
purpose4Rule = find(enforcementRules, hasPurpose(4))
purpose7Rule = find(enforcementRules, hasPurpose(7));

if (!purpose1Rule) {
purpose1Rule = DEFAULT_RULES[0];
}

if (!purpose2Rule) {
purpose2Rule = DEFAULT_RULES[1];
}
Object.entries(CONFIGURABLE_RULES).forEach(([name, opts]) => {
ACTIVE_RULES[opts.type][opts.id] = rules[name] ?? opts.default;
});

if (!hooksAdded) {
if (purpose1Rule) {
if (ACTIVE_RULES.purpose[1] != null) {
hooksAdded = true;
RULE_HANDLES.push(registerActivityControl(ACTIVITY_ACCESS_DEVICE, RULE_NAME, accessDeviceRule));
RULE_HANDLES.push(registerActivityControl(ACTIVITY_SYNC_USER, RULE_NAME, syncUserRule));
RULE_HANDLES.push(registerActivityControl(ACTIVITY_ENRICH_EIDS, RULE_NAME, enrichEidsRule));
}
if (purpose2Rule) {
if (ACTIVE_RULES.purpose[2] != null) {
RULE_HANDLES.push(registerActivityControl(ACTIVITY_FETCH_BIDS, RULE_NAME, fetchBidsRule));
}
if (purpose4Rule) {
if (ACTIVE_RULES.purpose[4] != null) {
RULE_HANDLES.push(
registerActivityControl(ACTIVITY_TRANSMIT_UFPD, RULE_NAME, ufpdRule),
registerActivityControl(ACTIVITY_ENRICH_UFPD, RULE_NAME, ufpdRule)
);
}
if (purpose7Rule) {
if (ACTIVE_RULES.purpose[7] != null) {
RULE_HANDLES.push(registerActivityControl(ACTIVITY_REPORT_ANALYTICS, RULE_NAME, reportAnalyticsRule));
}
if (ACTIVE_RULES.feature[1] != null) {
RULE_HANDLES.push(registerActivityControl(ACTIVITY_TRANSMIT_PRECISE_GEO, RULE_NAME, transmitPreciseGeoRule));
}
RULE_HANDLES.push(registerActivityControl(ACTIVITY_TRANSMIT_EIDS, RULE_NAME, transmitEidsRule));
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/activities/redactor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ import {
ACTIVITY_TRANSMIT_UFPD
} from './activities.js';

export const ORTB_UFPD_PATHS = ['user.data', 'user.ext.data', 'user.yob', 'user.gender', 'user.keywords', 'user.kwarray'];
export const ORTB_UFPD_PATHS = [
'data',
'ext.data',
'yob',
'gender',
'keywords',
'kwarray',
'id',
'buyeruid',
'customdata'
].map(f => `user.${f}`);
export const ORTB_EIDS_PATHS = ['user.eids', 'user.ext.eids'];
export const ORTB_GEO_PATHS = ['user.geo.lat', 'user.geo.lon', 'device.geo.lat', 'device.geo.lon'];

Expand Down
Loading

0 comments on commit 549a85c

Please sign in to comment.