diff --git a/libraries/mspa/activityControls.js b/libraries/mspa/activityControls.js index baaf81a8671..eaf515e2385 100644 --- a/libraries/mspa/activityControls.js +++ b/libraries/mspa/activityControls.js @@ -6,6 +6,7 @@ import { ACTIVITY_TRANSMIT_PRECISE_GEO } from '../../src/activities/activities.js'; import {gppDataHandler} from '../../src/adapterManager.js'; +import {logInfo} from '../../src/utils.js'; // default interpretation for MSPA consent(s): // https://docs.prebid.org/features/mspa-usnat.html @@ -112,8 +113,10 @@ function flatSection(subsections) { export function setupRules(api, sids, normalizeConsent = (c) => c, rules = CONSENT_RULES, registerRule = registerActivityControl, getConsentData = () => gppDataHandler.getConsentData()) { const unreg = []; + const ruleName = `MSPA (GPP '${api}' for section${sids.length > 1 ? 's' : ''} ${sids.join(', ')})`; + logInfo(`Enabling activity controls for ${ruleName}`) Object.entries(rules).forEach(([activity, denies]) => { - unreg.push(registerRule(activity, `MSPA (${api})`, mspaRule( + unreg.push(registerRule(activity, ruleName, mspaRule( sids, () => normalizeConsent(flatSection(getConsentData()?.parsedSections?.[api])), denies, diff --git a/modules/gppControl_usstates.js b/modules/gppControl_usstates.js new file mode 100644 index 00000000000..bc2b434e085 --- /dev/null +++ b/modules/gppControl_usstates.js @@ -0,0 +1,176 @@ +import {config} from '../src/config.js'; +import {setupRules} from '../libraries/mspa/activityControls.js'; +import {deepSetValue, prefixLog} from '../src/utils.js'; + +const FIELDS = { + Version: 0, + Gpc: 0, + SharingNotice: 0, + SaleOptOutNotice: 0, + SharingOptOutNotice: 0, + TargetedAdvertisingOptOutNotice: 0, + SensitiveDataProcessingOptOutNotice: 0, + SensitiveDataLimitUseNotice: 0, + SaleOptOut: 0, + SharingOptOut: 0, + TargetedAdvertisingOptOut: 0, + SensitiveDataProcessing: 12, + KnownChildSensitiveDataConsents: 2, + PersonalDataConsents: 0, + MspaCoveredTransaction: 0, + MspaOptOutOptionMode: 0, + MspaServiceProviderMode: 0, +}; + +/** + * Generate a normalization function for converting US state strings to the usnat format. + * + * Scalar fields are copied over if they exist in the input (state) data, or set to null otherwise. + * List fields are also copied, but forced to the "correct" length (by truncating or padding with nulls); + * additionally, elements within them can be moved around using the `move` argument. + * + * @param {Array[String]} nullify? list of fields to force to null + * @param {{}} move? Map from list field name to an index remapping for elements within that field (using 1 as the first index). + * For example, {SensitiveDataProcessing: {1: 2, 2: [1, 3]}} means "rearrange SensitiveDataProcessing by moving + * the first element to the second position, and the second element to both the first and third position." + * @param {({}, {}) => void} fn? an optional function to run once all the processing described above is complete; + * it's passed two arguments, the original (state) data, and its normalized (usnat) version. + * @param fields + * @returns {function({}): {}} + */ +export function normalizer({nullify = [], move = {}, fn}, fields = FIELDS) { + move = Object.fromEntries(Object.entries(move).map(([k, map]) => [k, + Object.fromEntries(Object.entries(map) + .map(([k, v]) => [k, Array.isArray(v) ? v : [v]]) + .map(([k, v]) => [--k, v.map(el => --el)]) + )]) + ); + return function (cd) { + const norm = Object.fromEntries(Object.entries(fields) + .map(([field, len]) => { + let val = null; + if (len > 0) { + val = Array(len).fill(null); + if (Array.isArray(cd[field])) { + const remap = move[field] || {}; + const done = []; + cd[field].forEach((el, i) => { + const [dest, moved] = remap.hasOwnProperty(i) ? [remap[i], true] : [[i], false]; + dest.forEach(d => { + if (d < len && !done.includes(d)) { + val[d] = el; + moved && done.push(d); + } + }); + }); + } + } else if (cd[field] != null) { + val = Array.isArray(cd[field]) ? null : cd[field]; + } + return [field, val]; + })); + nullify.forEach(path => deepSetValue(norm, path, null)); + fn && fn(cd, norm); + return norm; + }; +} + +function scalarMinorsAreChildren(original, normalized) { + normalized.KnownChildSensitiveDataConsents = original.KnownChildSensitiveDataConsents === 0 ? [0, 0] : [1, 1]; +} + +export const NORMALIZATIONS = { + // normalization rules - convert state consent into usnat consent + // https://docs.prebid.org/features/mspa-usnat.html + 7: (consent) => consent, + 8: normalizer({ + move: { + SensitiveDataProcessing: { + 1: 9, + 2: 10, + 3: 8, + 4: [1, 2], + 5: 12, + 8: 3, + 9: 4, + } + }, + fn(original, normalized) { + if (original.KnownChildSensitiveDataConsents.some(el => el !== 0)) { + normalized.KnownChildSensitiveDataConsents = [1, 1]; + } + } + }), + 9: normalizer({fn: scalarMinorsAreChildren}), + 10: normalizer({fn: scalarMinorsAreChildren}), + 11: normalizer({ + move: { + SensitiveDataProcessing: { + 3: 4, + 4: 5, + 5: 3, + } + }, + fn: scalarMinorsAreChildren + }), + 12: normalizer({ + fn(original, normalized) { + const cc = original.KnownChildSensitiveDataConsents; + let repl; + if (!cc.some(el => el !== 0)) { + repl = [0, 0]; + } else if (cc[1] === 2 && cc[2] === 2) { + repl = [2, 1]; + } else { + repl = [1, 1]; + } + normalized.KnownChildSensitiveDataConsents = repl; + } + }) +}; + +export const DEFAULT_SID_MAPPING = { + 8: 'usca', + 9: 'usva', + 10: 'usco', + 11: 'usut', + 12: 'usct' +}; + +export const getSections = (() => { + const allSIDs = Object.keys(DEFAULT_SID_MAPPING).map(Number); + return function ({sections = {}, sids = allSIDs} = {}) { + return sids.map(sid => { + const logger = prefixLog(`Cannot set up MSPA controls for SID ${sid}:`); + const ov = sections[sid] || {}; + const normalizeAs = ov.normalizeAs || sid; + if (!NORMALIZATIONS.hasOwnProperty(normalizeAs)) { + logger.logError(`no normalization rules are known for SID ${normalizeAs}`) + return; + } + const api = ov.name || DEFAULT_SID_MAPPING[sid]; + if (typeof api !== 'string') { + logger.logError(`cannot determine GPP section name`) + return; + } + return [ + api, + [sid], + NORMALIZATIONS[normalizeAs] + ] + }).filter(el => el != null); + } +})(); + +const handles = []; + +config.getConfig('consentManagement', (cfg) => { + const gppConf = cfg.consentManagement?.gpp; + if (gppConf) { + while (handles.length) { + handles.pop()(); + } + getSections(gppConf?.mspa || {}) + .forEach(([api, sids, normalize]) => handles.push(setupRules(api, sids, normalize))); + } +}); diff --git a/test/spec/modules/gppControl_usstates_spec.js b/test/spec/modules/gppControl_usstates_spec.js new file mode 100644 index 00000000000..1e9eb4176a8 --- /dev/null +++ b/test/spec/modules/gppControl_usstates_spec.js @@ -0,0 +1,519 @@ +import {DEFAULT_SID_MAPPING, getSections, NORMALIZATIONS, normalizer} from '../../../modules/gppControl_usstates.js'; + +describe('normalizer', () => { + it('sets nullify fields to null', () => { + const res = normalizer({ + nullify: [ + 'field', + 'arr.1' + ] + }, { + untouched: 0, + field: 0, + arr: 3 + })({ + untouched: 1, + field: 2, + arr: ['a', 'b', 'c'] + }); + sinon.assert.match(res, { + untouched: 1, + field: null, + arr: ['a', null, 'c'] + }); + }); + it('initializes scalar fields to null', () => { + const res = normalizer({}, {untouched: 0, f1: 0, f2: 0})({untouched: 0}); + expect(res).to.eql({ + untouched: 0, + f1: null, + f2: null, + }) + }) + it('initializes list fields to null-array with correct size', () => { + const res = normalizer({}, {'l1': 2, 'l2': 3})({}); + expect(res).to.eql({ + l1: [null, null], + l2: [null, null, null] + }); + }); + Object.entries({ + 'arrays of the same size': [ + [1, 2], + [1, 2] + ], + 'arrays of the same size, with moves': [ + [1, 2, 3], + [1, 3, 2], + {2: 3, 3: 2} + ], + 'original larger than normal': [ + [1, 2, 3], + [1, 2] + ], + 'original larger than normal, with moves': [ + [1, 2, 3], + [null, 1], + {1: 2} + ], + 'normal larger than original': [ + [1, 2], + [1, 2, null] + ], + 'normal larger than original, with moves': [ + [1, 2], + [2, null, 2], + {2: [1, 3]} + ], + 'original is scalar': [ + 'value', + [null, null] + ], + 'normalized is scalar': [ + [0, 1], + null + ] + }).forEach(([t, [from, to, move]]) => { + it(`carries over values for list fields - ${t}`, () => { + const res = normalizer({move: {field: move || {}}}, {field: Array.isArray(to) ? to.length : 0})({field: from}); + expect(res.field).to.eql(to); + }); + }); + + it('runs fn as a final step', () => { + const fn = sinon.stub().callsFake((orig, normalized) => { + normalized.fn = true; + }); + const orig = { + untouched: 0, + nulled: 1, + multi: ['a', 'b', 'c'] + }; + const res = normalizer({ + nullify: ['nulled'], + move: { + multi: {1: 2} + }, + fn + }, {nulled: 0, untouched: 0, multi: 2})(orig); + const transformed = { + nulled: null, + untouched: 0, + multi: [null, 'a'] + }; + sinon.assert.calledWith(fn, orig, sinon.match(transformed)); + expect(res).to.eql(Object.assign({fn: true}, transformed)); + }); +}); + +describe('state normalizations', () => { + Object.entries({ + 'California/8': [ + 8, + { + Version: 'version', + SaleOptOutNotice: 'saleOON', + SharingOptOutNotice: 'sharingOON', + SensitiveDataLimitUseNotice: 'sensDLUN', + SaleOptOut: 'saleOO', + SharingOptOut: 'sharingOO', + PersonalDataConsents: 'PDC', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + Gpc: 'gpc', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + KnownChildSensitiveDataConsents: [ + 1, + 0 + ], + }, + { + Version: 'version', + SaleOptOutNotice: 'saleOON', + SharingOptOutNotice: 'sharingOON', + SensitiveDataLimitUseNotice: 'sensDLUN', + SaleOptOut: 'saleOO', + SharingOptOut: 'sharingOO', + Gpc: 'gpc', + PersonalDataConsents: 'PDC', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + SharingNotice: null, + TargetedAdvertisingOptOutNotice: null, + SensitiveDataProcessingOptOutNotice: null, + TargetedAdvertisingOptOut: null, + SensitiveDataProcessing: [ + 4, + 4, + 8, + 9, + null, + 6, + 7, + 3, + 1, + 2, + null, + 5 + ], + KnownChildSensitiveDataConsents: [1, 1], + } + ], + 'Virginia/9': [ + 9, + { + Version: 'version', + SharingNotice: 'sharingN', + SaleOptOutNotice: 'saleOON', + SaleOptOut: 'saleOO', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + ], + KnownChildSensitiveDataConsents: 2, + }, + { + Version: 'version', + SaleOptOutNotice: 'saleOON', + SharingOptOutNotice: null, + SensitiveDataLimitUseNotice: null, + SensitiveDataProcessingOptOutNotice: null, + SaleOptOut: 'saleOO', + SharingOptOut: null, + PersonalDataConsents: null, + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + Gpc: null, + SharingNotice: 'sharingN', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + null, + null, + null, + null, + ], + KnownChildSensitiveDataConsents: [1, 1], + } + ], + 'Colorado/10': [ + 10, + { + Gpc: 'gpc', + Version: 'version', + SharingNotice: 'sharingN', + SaleOptOutNotice: 'saleOON', + SaleOptOut: 'saleOO', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ], + KnownChildSensitiveDataConsents: 2, + }, + { + Version: 'version', + SaleOptOutNotice: 'saleOON', + SharingOptOutNotice: null, + SensitiveDataLimitUseNotice: null, + SensitiveDataProcessingOptOutNotice: null, + SaleOptOut: 'saleOO', + SharingOptOut: null, + PersonalDataConsents: null, + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + Gpc: 'gpc', + SharingNotice: 'sharingN', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + null, + null, + null, + null, + null, + ], + KnownChildSensitiveDataConsents: [1, 1], + } + ], + 'Utah/11': [ + 11, + { + Version: 'version', + SharingNotice: 'sharingN', + SaleOptOutNotice: 'saleOON', + SaleOptOut: 'saleOO', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessingOptOutNotice: 'SDPOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + ], + KnownChildSensitiveDataConsents: 1, + }, + { + Gpc: null, + Version: 'version', + SharingNotice: 'sharingN', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SaleOptOut: 'saleOO', + SaleOptOutNotice: 'saleOON', + SensitiveDataProcessing: [ + 1, + 2, + 5, + 3, + 4, + 6, + 7, + 8, + null, + null, + null, + null, + ], + KnownChildSensitiveDataConsents: [1, 1], + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + SharingOptOutNotice: null, + SharingOptOut: null, + SensitiveDataLimitUseNotice: null, + SensitiveDataProcessingOptOutNotice: 'SDPOON', + PersonalDataConsents: null, + } + ], + 'Connecticut/12': [ + 12, + { + Gpc: 'gpc', + Version: 'version', + SharingNotice: 'sharingN', + SaleOptOutNotice: 'saleOON', + SaleOptOut: 'saleOO', + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + ], + KnownChildSensitiveDataConsents: [0, 0, 0], + }, + { + Gpc: 'gpc', + Version: 'version', + SharingNotice: 'sharingN', + TargetedAdvertisingOptOut: 'TAOO', + TargetedAdvertisingOptOutNotice: 'TAOON', + SaleOptOut: 'saleOO', + SaleOptOutNotice: 'saleOON', + SensitiveDataProcessing: [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + null, + null, + null, + null, + ], + KnownChildSensitiveDataConsents: [0, 0], + MspaCoveredTransaction: 'MCT', + MspaOptOutOptionMode: 'MOOOM', + MspaServiceProviderMode: 'MSPM', + SharingOptOutNotice: null, + SharingOptOut: null, + SensitiveDataLimitUseNotice: null, + SensitiveDataProcessingOptOutNotice: null, + PersonalDataConsents: null, + } + ] + }).forEach(([t, [sid, original, normalized]]) => { + it(t, () => { + expect(NORMALIZATIONS[sid](original)).to.eql(normalized); + }) + }); + + describe('child consent', () => { + function checkChildConsent(sid, orig, normalized) { + expect(NORMALIZATIONS[sid]({ + KnownChildSensitiveDataConsents: orig + }).KnownChildSensitiveDataConsents).to.eql(normalized) + } + + describe('states with single flag', () => { + Object.entries({ + 'Virginia/9': 9, + 'Colorado/10': 10, + 'Utah/11': 11, + }).forEach(([t, sid]) => { + describe(t, () => { + Object.entries({ + 0: [0, 0], + 1: [1, 1], + 2: [1, 1] + }).forEach(([orig, normalized]) => { + orig = Number(orig); + it(`translates ${orig} to ${normalized}`, () => { + checkChildConsent(sid, orig, normalized); + }) + }) + }) + }); + }) + + Object.entries({ + 'CA/8, consent not known': [ + 8, + [0, 0], + [0, 0] + ], + 'CA/8, first flag applies': [ + 8, + [1, 0], + [1, 1] + ], + 'CA/8, second flag applies': [ + 8, + [0, 2], + [1, 1] + ], + 'CT/12, consent not known': [ + 12, + [0, 0, 0], + [0, 0] + ], + 'CT/12, teenager consent': [ + 12, + [1, 2, 2], + [2, 1] + ], + 'CT/12, no consent': [ + 12, + [0, 1, 2], + [1, 1] + ] + }).forEach(([t, [sid, orig, normalized]]) => { + it(t, () => { + checkChildConsent(sid, orig, normalized); + }) + }) + }) +}); + +describe('getSections', () => { + it('returns default values for all sections', () => { + const expected = Object.entries(DEFAULT_SID_MAPPING).map(([sid, api]) => [ + api, + [Number(sid)], + NORMALIZATIONS[sid] + ]); + expect(getSections()).to.eql(expected); + }); + + it('filters by sid', () => { + expect(getSections({sids: [8]})).to.eql([ + ['usca', [8], NORMALIZATIONS[8]] + ]); + }); + + it('can override api name', () => { + expect(getSections({ + sids: [8], + sections: { + 8: { + name: 'uspv1ca' + } + } + })).to.eql([ + ['uspv1ca', [8], NORMALIZATIONS[8]] + ]) + }); + + it('can override normalization', () => { + expect(getSections({ + sids: [8, 9], + sections: { + 8: { + normalizeAs: 9 + } + } + })).to.eql([ + ['usca', [8], NORMALIZATIONS[9]], + ['usva', [9], NORMALIZATIONS[9]] + ]) + }); +})