diff --git a/libraries/mspa/activityControls.js b/libraries/mspa/activityControls.js index 3359862a5b3..9c8e393f064 100644 --- a/libraries/mspa/activityControls.js +++ b/libraries/mspa/activityControls.js @@ -8,7 +8,13 @@ import { import {gppDataHandler} from '../../src/adapterManager.js'; // default interpretation for MSPA consent(s): -// https://docs.google.com/document/d/1yPEVx3bBdSkIw-QNkQR5qi1ztmn9zoXk-LaGQB6iw7Q +// https://docs.prebid.org/features/mspa-usnat.html + +const SENSITIVE_DATA_GEO = 7; + +function isApplicable(val) { + return val != null && val !== 0 +} export function isBasicConsentDenied(cd) { // service provider mode is always consent denied @@ -18,47 +24,60 @@ export function isBasicConsentDenied(cd) { // minors 13+ who have not given consent cd.KnownChildSensitiveDataConsents[0] === 1 || // minors under 13 cannot consent - cd.KnownChildSensitiveDataConsents[1] !== 0 || - // sensitive category consent impossible without notice - (cd.SensitiveDataProcessing.some(element => element === 2) && (cd.SensitiveDataLimitUseNotice !== 1 || cd.SensitiveDataProcessingOptOutNotice !== 1)); + isApplicable(cd.KnownChildSensitiveDataConsents[1]); } -export function isSensitiveNoticeMissing(cd) { - return ['SensitiveDataProcessingOptOutNotice', 'SensitiveDataLimitUseNotice'].some(prop => cd[prop] === 2) +export function sensitiveNoticeIs(cd, value) { + return ['SensitiveDataProcessingOptOutNotice', 'SensitiveDataLimitUseNotice'].some(prop => cd[prop] === value) } export function isConsentDenied(cd) { return isBasicConsentDenied(cd) || - // user opts out - (['SaleOptOut', 'SharingOptOut', 'TargetedAdvertisingOptOut'].some(prop => cd[prop] === 1)) || - // notice not given - (['SaleOptOutNotice', 'SharingNotice', 'SharingOptOutNotice', 'TargetedAdvertisingOptOutNotice'].some(prop => cd[prop] === 2)) || - // sale opted in but notice does not apply - ((cd.SaleOptOut === 2 && cd.SaleOptOutNotice === 0)) || - // targeted opted in but notice does not apply - ((cd.TargetedAdvertisingOptOut === 2 && cd.TargetedAdvertisingOptOutNotice === 0)) || - // sharing opted in but notices do not apply - ((cd.SharingOptOut === 2 && (cd.SharingNotice === 0 || cd.SharingOptOutNotice === 0))); + ['Sale', 'Sharing', 'TargetedAdvertising'].some(scope => { + const oo = cd[`${scope}OptOut`]; + const notice = cd[`${scope}OptOutNotice`]; + // user opted out + return oo === 1 || + // opt-out notice was not given + notice === 2 || + // do not trust CMP if it signals opt-in but no opt-out notice was given + (oo === 2 && notice === 0); + }) || + // no sharing notice was given ... + cd.SharingNotice === 2 || + // ... or the CMP says it was not applicable, while also claiming it got consent + (cd.SharingOptOut === 2 && cd.SharingNotice === 0); } -export function isTransmitUfpdConsentDenied(cd) { - // SensitiveDataProcessing[1-5,11]=1 OR SensitiveDataProcessing[6,7,9,10,12]<>0 OR - const mustBeZero = [6, 7, 9, 10, 12]; - const mustBeZeroSubtractedVector = mustBeZero.map((number) => number - 1); - const SensitiveDataProcessingMustBeZero = cd.SensitiveDataProcessing.filter(index => mustBeZeroSubtractedVector.includes(index)); - const cannotBeOne = [1, 2, 3, 4, 5, 11]; - const cannotBeOneSubtractedVector = cannotBeOne.map((number) => number - 1); - const SensitiveDataProcessingCannotBeOne = cd.SensitiveDataProcessing.filter(index => cannotBeOneSubtractedVector.includes(index)); - return isConsentDenied(cd) || - isSensitiveNoticeMissing(cd) || - SensitiveDataProcessingCannotBeOne.some(val => val === 1) || - SensitiveDataProcessingMustBeZero.some(val => val !== 0); -} +export const isTransmitUfpdConsentDenied = (() => { + // deny anything that smells like: genetic, biometric, state/national ID, financial, union membership, + // or personal communication data + const cannotBeInScope = [6, 7, 9, 10, 12].map(el => --el); + // require consent for everything else (except geo, which is treated separately) + const allExceptGeo = Array.from(Array(12).keys()).filter((el) => el !== SENSITIVE_DATA_GEO) + const mustHaveConsent = allExceptGeo.filter(el => !cannotBeInScope.includes(el)); + + return function (cd) { + return isConsentDenied(cd) || + // no notice about sensitive data was given + sensitiveNoticeIs(cd, 2) || + // extra-sensitive data is applicable + cannotBeInScope.some(i => isApplicable(cd.SensitiveDataProcessing[i])) || + // user opted out for not-as-sensitive data + mustHaveConsent.some(i => cd.SensitiveDataProcessing[i] === 1) || + // CMP says it has consent, but did not give notice about it + (sensitiveNoticeIs(cd, 0) && allExceptGeo.some(i => cd.SensitiveDataProcessing[i] === 2)) + } +})(); export function isTransmitGeoConsentDenied(cd) { - return isBasicConsentDenied(cd) || - isSensitiveNoticeMissing(cd) || - cd.SensitiveDataProcessing[7] === 1 + const geoConsent = cd.SensitiveDataProcessing[SENSITIVE_DATA_GEO]; + return geoConsent === 1 || + isBasicConsentDenied(cd) || + // no sensitive data notice was given + sensitiveNoticeIs(cd, 2) || + // do not trust CMP if it says it has consent for geo but didn't show a sensitive data notice + (sensitiveNoticeIs(cd, 0) && geoConsent === 2) } const CONSENT_RULES = { diff --git a/test/spec/libraries/mspa/activityControls_spec.js b/test/spec/libraries/mspa/activityControls_spec.js index 5286f1d47f0..eab99dc43ee 100644 --- a/test/spec/libraries/mspa/activityControls_spec.js +++ b/test/spec/libraries/mspa/activityControls_spec.js @@ -1,220 +1,153 @@ -import {mspaRule, setupRules, isTransmitUfpdConsentDenied, isTransmitGeoConsentDenied, isBasicConsentDenied, isSensitiveNoticeMissing, isConsentDenied} from '../../../../libraries/mspa/activityControls.js'; +import {mspaRule, setupRules, isTransmitUfpdConsentDenied, isTransmitGeoConsentDenied, isBasicConsentDenied, sensitiveNoticeIs, isConsentDenied} from '../../../../libraries/mspa/activityControls.js'; import {ruleRegistry} from '../../../../src/activities/rules.js'; -describe('isBasicConsentDenied', () => { - const cd = { - // not covered, opt in to targeted, sale, and share, all notices given, opt into precise geo - Gpc: 0, - KnownChildSensitiveDataConsents: [0, 0], - MspaCoveredTransaction: 2, - MspaOptOutOptionMode: 0, - MspaServiceProviderMode: 0, - PersonalDataConsents: 0, - SaleOptOut: 2, - SaleOptOutNotice: 1, - SensitiveDataLimitUseNotice: 1, - SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0], - SensitiveDataProcessingOptOutNotice: 1, - SharingNotice: 1, - SharingOptOut: 2, - SharingOptOutNotice: 1, - TargetedAdvertisingOptOut: 2, - TargetedAdvertisingOptOutNotice: 1, - Version: 1 - }; - it('should be false (basic consent conditions pass) with variety of notice and opt in', () => { - const result = isBasicConsentDenied(cd); - expect(result).to.equal(false); - }); - it('should be true (basic consent conditions do not pass) with personal data consent set to true (invalid state)', () => { - cd.PersonalDataConsents = 2; - const result = isBasicConsentDenied(cd); - expect(result).to.equal(true); - cd.PersonalDataConsents = 0; - }); - it('should be true (basic consent conditions do not pass) with sensitive opt in but no notice', () => { - cd.SensitiveDataLimitUseNotice = 0; - const result = isBasicConsentDenied(cd); - expect(result).to.equal(true); - cd.SensitiveDataLimitUseNotice = 1; - }); -}) -describe('isSensitiveNoticeMissing', () => { - const cd = { - // not covered, opt in to targeted, sale, and share, all notices given, opt into precise geo - Gpc: 0, - KnownChildSensitiveDataConsents: [0, 0], - MspaCoveredTransaction: 2, - MspaOptOutOptionMode: 0, - MspaServiceProviderMode: 0, - PersonalDataConsents: 0, - SaleOptOut: 2, - SaleOptOutNotice: 1, - SensitiveDataLimitUseNotice: 1, - SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0], - SensitiveDataProcessingOptOutNotice: 1, - SharingNotice: 1, - SharingOptOut: 2, - SharingOptOutNotice: 1, - TargetedAdvertisingOptOut: 2, - TargetedAdvertisingOptOutNotice: 1, - Version: 1 - }; - it('should be false (sensitive notice is given or not needed) with variety of notice and opt in', () => { - const result = isSensitiveNoticeMissing(cd); - expect(result).to.equal(false); - }); - it('should be true (sensitive notice is missing) with variety of notice and opt in', () => { - cd.SensitiveDataLimitUseNotice = 2; - const result = isSensitiveNoticeMissing(cd); - expect(result).to.equal(true); - cd.SensitiveDataLimitUseNotice = 1; - }); -}) -describe('isConsentDenied', () => { - const cd = { - // not covered, opt in to targeted, sale, and share, all notices given, opt into precise geo - Gpc: 0, - KnownChildSensitiveDataConsents: [0, 0], - MspaCoveredTransaction: 2, - MspaOptOutOptionMode: 0, - MspaServiceProviderMode: 0, - PersonalDataConsents: 0, - SaleOptOut: 2, - SaleOptOutNotice: 1, - SensitiveDataLimitUseNotice: 1, - SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0], - SensitiveDataProcessingOptOutNotice: 1, - SharingNotice: 1, - SharingOptOut: 2, - SharingOptOutNotice: 1, - TargetedAdvertisingOptOut: 2, - TargetedAdvertisingOptOutNotice: 1, - Version: 1 - }; - it('should be false (consent given personalized ads / sale / share) with variety of notice and opt in', () => { - const result = isConsentDenied(cd); - expect(result).to.equal(false); - }); - it('should be true (no consent) on opt out of targeted ads via TargetedAdvertisingOptOut', () => { - cd.TargetedAdvertisingOptOut = 1; - const result = isConsentDenied(cd); - expect(result).to.equal(true); - cd.TargetedAdvertisingOptOut = 2; - }); - it('should be true (no consent) on opt out of targeted ads via no TargetedAdvertisingOptOutNotice', () => { - cd.TargetedAdvertisingOptOutNotice = 2; - const result = isConsentDenied(cd); - expect(result).to.equal(true); - cd.TargetedAdvertisingOptOutNotice = 1; - }); - it('should be true (no consent) if TargetedAdvertisingOptOutNotice is 0 and TargetedAdvertisingOptOut is 2', () => { - cd.TargetedAdvertisingOptOutNotice = 0; - const result = isConsentDenied(cd); - expect(result).to.equal(true); - cd.TargetedAdvertisingOptOutNotice = 1; - }); -}) -describe('isTransmitUfpdConsentDenied', () => { - const cd = { - // not covered, opt in to targeted, sale, and share, all notices given, opt into precise geo - Gpc: 0, - KnownChildSensitiveDataConsents: [0, 0], - MspaCoveredTransaction: 2, - MspaOptOutOptionMode: 0, - MspaServiceProviderMode: 0, - PersonalDataConsents: 0, - SaleOptOut: 2, - SaleOptOutNotice: 1, - SensitiveDataLimitUseNotice: 1, - SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0], - SensitiveDataProcessingOptOutNotice: 1, - SharingNotice: 1, - SharingOptOut: 2, - SharingOptOutNotice: 1, - TargetedAdvertisingOptOut: 2, - TargetedAdvertisingOptOutNotice: 1, - Version: 1 - }; - it('should be false (consent given to add ufpd) with variety of notice and opt in', () => { - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(false); - }); - it('should be true (consent denied to add ufpd) if no consent to process health information', () => { - cd.SensitiveDataProcessing[2] = 1; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.SensitiveDataProcessing[2] = 0; - }); - it('should be true (consent denied to add ufpd) with consent to process biometric data, as this should not be on openrtb', () => { - cd.SensitiveDataProcessing[6] = 1; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.SensitiveDataProcessing[6] = 1; - }); - it('should be true (consent denied to add ufpd) without sharing notice', () => { - cd.SharingNotice = 2; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.SharingNotice = 1; - }); - it('should be true (consent denied to add ufpd) with sale opt out', () => { - cd.SaleOptOut = 1; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.SaleOptOut = 2; - }); - it('should be true (consent denied to add ufpd) without targeted ads opt out', () => { - cd.TargetedAdvertisingOptOut = 1; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.TargetedAdvertisingOptOut = 2; - }); - it('should be true (consent denied to add ufpd) with missing sensitive data limit notice', () => { - cd.SensitiveDataLimitUseNotice = 2; - const result = isTransmitUfpdConsentDenied(cd); - expect(result).to.equal(true); - cd.SensitiveDataLimitUseNotice = 1; - }); -}) -describe('isTransmitGeoConsentDenied', () => { - const cd = { - // not covered, opt out of geo - Gpc: 0, - KnownChildSensitiveDataConsents: [0, 0], - MspaCoveredTransaction: 2, - MspaOptOutOptionMode: 0, - MspaServiceProviderMode: 0, - PersonalDataConsents: 0, - SaleOptOut: 2, - SaleOptOutNotice: 1, - SensitiveDataLimitUseNotice: 1, - SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], - SensitiveDataProcessingOptOutNotice: 1, - SharingNotice: 1, - SharingOptOut: 2, - SharingOptOutNotice: 1, - TargetedAdvertisingOptOut: 2, - TargetedAdvertisingOptOutNotice: 1, - Version: 1 - }; - it('should be true (consent denied to add precise geo) -- sensitive flag denied', () => { - const result = isTransmitGeoConsentDenied(cd); - expect(result).to.equal(true); +describe('Consent interpretation', () => { + function mkConsent(flags) { + return Object.assign({ + // not covered, opt in to targeted, sale, and share, all notices given, opt into precise geo + Gpc: 0, + KnownChildSensitiveDataConsents: [0, 0], + MspaCoveredTransaction: 2, + MspaOptOutOptionMode: 0, + MspaServiceProviderMode: 0, + PersonalDataConsents: 0, + SaleOptOut: 2, + SaleOptOutNotice: 1, + SensitiveDataLimitUseNotice: 1, + SensitiveDataProcessing: [0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0], + SensitiveDataProcessingOptOutNotice: 1, + SharingNotice: 1, + SharingOptOut: 2, + SharingOptOutNotice: 1, + TargetedAdvertisingOptOut: 2, + TargetedAdvertisingOptOutNotice: 1, + Version: 1 + }, flags) + } + describe('isBasicConsentDenied', () => { + it('should be false (basic consent conditions pass) with variety of notice and opt in', () => { + const result = isBasicConsentDenied(mkConsent()); + expect(result).to.equal(false); + }); + it('should be true (basic consent conditions do not pass) with personal data consent set to true (invalid state)', () => { + const result = isBasicConsentDenied(mkConsent({ + PersonalDataConsents: 2 + })); + expect(result).to.equal(true); + }); + it('should not deny when consent for under-13 is null', () => { + expect(isBasicConsentDenied(mkConsent({ + KnownChildSensitiveDataConsents: [0, null] + }))).to.be.false; + }) }); - it('should be true (consent denied to add precise geo) -- sensitive data limit usage not given', () => { - cd.SensitiveDataLimitUseNotice = 0; - const result = isTransmitGeoConsentDenied(cd); - expect(result).to.equal(true); - cd.SensitiveDataLimitUseNotice = 1; + + describe('isConsentDenied', () => { + it('should be false (consent given personalized ads / sale / share) with variety of notice and opt in', () => { + const result = isConsentDenied(mkConsent()); + expect(result).to.equal(false); + }); + it('should be true (no consent) on opt out of targeted ads via TargetedAdvertisingOptOut', () => { + const result = isConsentDenied(mkConsent({ + TargetedAdvertisingOptOut: 1 + })); + expect(result).to.equal(true); + }); + it('should be true (no consent) on opt out of targeted ads via no TargetedAdvertisingOptOutNotice', () => { + const result = isConsentDenied(mkConsent({ + TargetedAdvertisingOptOutNotice: 2 + })); + expect(result).to.equal(true); + }); + it('should be true (no consent) if TargetedAdvertisingOptOutNotice is 0 and TargetedAdvertisingOptOut is 2', () => { + const result = isConsentDenied(mkConsent({ + TargetedAdvertisingOptOutNotice: 0 + })); + expect(result).to.equal(true); + }); + it('requires also SharingNotice to accept opt-in for Sharing', () => { + expect(isConsentDenied(mkConsent({ + SharingNotice: 0 + }))).to.be.true; + }) }); - it('should be false (consent given to add precise geo) -- sensitive position 8 (index 7) is true', () => { - cd.SensitiveDataProcessing[7] = 2; - const result = isTransmitGeoConsentDenied(cd); - expect(result).to.equal(false); - cd.SensitiveDataProcessing[7] = 1; + + describe('isTransmitUfpdConsentDenied', () => { + it('should be false (consent given to add ufpd) with variety of notice and opt in', () => { + const result = isTransmitUfpdConsentDenied(mkConsent()); + expect(result).to.equal(false); + }); + Object.entries({ + 'health information': 2, + 'biometric data': 6, + }).forEach(([t, flagNo]) => { + it(`'should be true (consent denied to add ufpd) if no consent to process ${t}'`, () => { + const consent = mkConsent(); + consent.SensitiveDataProcessing[flagNo] = 1; + expect(isTransmitUfpdConsentDenied(consent)).to.be.true; + }) + }); + + ['SharingNotice', 'SensitiveDataLimitUseNotice'].forEach(flag => { + it(`should be true (consent denied to add ufpd) without ${flag}`, () => { + expect(isTransmitUfpdConsentDenied(mkConsent({ + [flag]: 2 + }))).to.be.true; + }) + }); + + ['SaleOptOut', 'TargetedAdvertisingOptOut'].forEach(flag => { + it(`should be true (consent denied to add ufpd) with ${flag}`, () => { + expect(isTransmitUfpdConsentDenied(mkConsent({ + [flag]: 1 + }))).to.be.true; + }) + }); + + it('should be true (basic consent conditions do not pass) with sensitive opt in but no notice', () => { + const cd = mkConsent({ + SensitiveDataLimitUseNotice: 0 + }); + cd.SensitiveDataProcessing[0] = 2; + expect(isTransmitUfpdConsentDenied(cd)).to.be.true; + }); + + it('should deny when sensitive notice is missing', () => { + const result = isTransmitUfpdConsentDenied(mkConsent({ + SensitiveDataLimitUseNotice: 2 + })); + expect(result).to.equal(true); + }); + + it('should not deny when biometric data opt-out is null', () => { + const cd = mkConsent(); + cd.SensitiveDataProcessing[6] = null; + expect(isTransmitUfpdConsentDenied(cd)).to.be.false; + }) }); -}) + + describe('isTransmitGeoConsentDenied', () => { + function geoConsent(geoOptOut, flags) { + const consent = mkConsent(flags); + consent.SensitiveDataProcessing[7] = geoOptOut; + return consent; + } + it('should be true (consent denied to add precise geo) -- sensitive flag denied', () => { + const result = isTransmitGeoConsentDenied(geoConsent(1)); + expect(result).to.equal(true); + }); + it('should be true (consent denied to add precise geo) -- sensitive data limit usage not given', () => { + const result = isTransmitGeoConsentDenied(geoConsent(1, { + SensitiveDataLimitUseNotice: 0 + })); + expect(result).to.equal(true); + }); + it('should be false (consent given to add precise geo) -- sensitive position 8 (index 7) is true', () => { + const result = isTransmitGeoConsentDenied(geoConsent(2)); + expect(result).to.equal(false); + }); + }) +}); describe('mspaRule', () => { it('does not apply if SID is not applicable', () => {