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

AzerionEdge RTD Module: Compatibility with GDPR/USP Privacy Modules #11775

Merged
merged 10 commits into from
Jul 6, 2024
82 changes: 78 additions & 4 deletions modules/azerionedgeRtdProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const REAL_TIME_MODULE = 'realTimeData';
const SUBREAL_TIME_MODULE = 'azerionedge';
export const STORAGE_KEY = 'ht-pa-v1-a';

const IMPROVEDIGITAL_GVLID = '253';
const PURPOSES = ['1', '3', '5', '7', '9'];

export const storage = getStorageManager({
moduleType: MODULE_TYPE_RTD,
moduleName: SUBREAL_TIME_MODULE,
Expand Down Expand Up @@ -106,10 +109,79 @@ export function setAudiencesToBidders(reqBidsConfigObj, config, audiences) {
* @return {boolean}
*/
function init(config, userConsent) {
attachScript(config);
if (hasUserConsented(userConsent)) {
attachScript(config);
}
return true;
}

/**
* List the vendors consented coming from userConsent object.
*
* @param {Object} userConsent
*
* @return {Array}
*/
function getVendorsConsented(userConsent) {
const consents = userConsent?.gdpr?.vendorData?.vendor?.consents || {};
return Object.entries(consents).reduce((acc, [vendorId, consented]) => {
return consented ? [...acc, vendorId] : acc;
}, []);
}

/**
* List the purposes consented coming from userConsent object.
*
* @param {Object} userConsent
*
* @return {Array}
*/
export function getPurposesConsented(userConsent) {
const consents = userConsent?.gdpr?.vendorData?.purpose?.consents || {};
return Object.entries(consents).reduce((acc, [purposeId, consented]) => {
return consented ? [...acc, purposeId] : acc;
}, []);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this something the tcfConsentManagement module should handle?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure we understood the functionality of the consentManagement module correctly. Please let me know if anything that I'm saying is incorrect.

The consentManagment module is used to fetch, decode and pass the user consent to bid adapter, RTD modules, etc. The publisher uses this module to configure the integration with its CMP. It also allows adding some rules to automatically enforce GDPR based on their GVL_ID through the gdprEnforcement module. Like the one shown here as Sirdata's example:

pbjs.setConfig({
    consentManagement: {
        usp: {
            cmpApi: 'iab',
            timeout: 100 // US Privacy timeout 100ms
        },
        gdpr: {
          cmpApi: 'iab',
          timeout: 8000,
          defaultGdprScope: true,
          rules: [{
            purpose: "storage",
            enforcePurpose: true,
            enforceVendor: true,
            vendorExceptions: ["appnexus"] //Can work without consent for cookies
          },{
            purpose: "basicAds",
            enforcePurpose: true,
            enforceVendor: true,
            vendorExceptions: ["appnexus"]
          }]
        },
    }
});

But if we want to automatically check the consent given for our module from inside our RTD module, without the publisher having to configure that, we can't use the consentManagement module. We've seen other modules doing the checks manually like in our case. For instance sirdata.

We are not sure if there's a better way of doing this through any helpers, or exported functions from consentManagement/gdprEnforcement modules. The closest we could find was the method hasPurpose1Consent but it only checks for one purpose and we need to check five.

Any insight or suggestion about how to better approach this would be greatly appreciated.


/**
* Checks if GDPR gives us access through the userConsent object.
*
* @param {Object} userConsent
*
* @return {boolean}
*/
export function hasGDPRAccess(userConsent) {
const gdprApplies = userConsent?.gdpr?.gdprApplies;
const isVendorAllowed = getVendorsConsented(userConsent).includes(IMPROVEDIGITAL_GVLID);
const arePurposesAllowed = PURPOSES.every((purpose) => getPurposesConsented(userConsent).includes(purpose));
return !gdprApplies || (isVendorAllowed && arePurposesAllowed);
}

/**
* Checks if USP gives us access through the userConsent object.
*
* @param {Object} userConsent
*
* @return {boolean}
*/
export function hasUSPAccess(userConsent) {
const uspProvided = userConsent?.usp;
const hasProvidedUserNotice = uspProvided?.[1] !== 'N';
const hasNotOptedOut = uspProvided?.[2] !== 'Y';
return !uspProvided || (hasProvidedUserNotice && hasNotOptedOut);
}

/**
* Checks if GDPR/USP gives us access through the userConsent object.
*
* @param {Object} userConsent
*
* @return {boolean}
*/
export function hasUserConsented(userConsent) {
return hasGDPRAccess(userConsent) && hasUSPAccess(userConsent);
}

/**
* Real-time user audiences retrieval
*
Expand All @@ -126,9 +198,11 @@ export function getBidRequestData(
config,
userConsent
) {
const audiences = getAudiences();
if (audiences.length > 0) {
setAudiencesToBidders(reqBidsConfigObj, config, audiences);
if (hasUserConsented(userConsent)) {
const audiences = getAudiences();
if (audiences.length > 0) {
setAudiencesToBidders(reqBidsConfigObj, config, audiences);
}
}
callback();
}
Expand Down
17 changes: 0 additions & 17 deletions modules/azerionedgeRtdProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,23 +79,6 @@ provided to the module when the user gives the relevant permissions on the publi
As Prebid.js utilizes TCF vendor consent for the RTD module to load, the module needs to be labeled
within the Vendor Exceptions.

### Instructions

If the Prebid GDPR enforcement is enabled, the module should be labeled
as exception, as shown below:

```js
[
{
purpose: 'storage',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: ["azerionedge"]
},
...
]
```

## Testing

To view an example:
Expand Down
110 changes: 106 additions & 4 deletions test/spec/modules/azerionedgeRtdProvider_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ describe('Azerion Edge RTD submodule', function () {
const bidders = ['appnexus', 'improvedigital'];
const process = { key: 'value' };
const dataProvider = { name: 'azerionedge', waitForIt: true };
const tcfGDPRNotApplicable = { gdprApplies: false };
const uspNotProvided = { usp: undefined };
const ignoreConsent = {gdpr: tcfGDPRNotApplicable, usp: uspNotProvided};

let reqBidsConfigObj;
let storageStub;
Expand All @@ -33,7 +36,7 @@ describe('Azerion Edge RTD submodule', function () {
let returned;

beforeEach(function () {
returned = azerionedgeRTD.azerionedgeSubmodule.init(dataProvider);
returned = azerionedgeRTD.azerionedgeSubmodule.init(dataProvider, ignoreConsent);
});

it('should return true', function () {
Expand All @@ -60,7 +63,7 @@ describe('Azerion Edge RTD submodule', function () {
returned = azerionedgeRTD.azerionedgeSubmodule.init({
...dataProvider,
params: { key },
});
}, ignoreConsent);
});

it('should return true', function () {
Expand All @@ -80,7 +83,7 @@ describe('Azerion Edge RTD submodule', function () {
returned = azerionedgeRTD.azerionedgeSubmodule.init({
...dataProvider,
params: { process },
});
}, ignoreConsent);
});

it('should return true', function () {
Expand All @@ -95,6 +98,105 @@ describe('Azerion Edge RTD submodule', function () {
});
});

describe('GDPR access', () => {
const vendorConsented = { '253': true }
const purposesConsented = {'1': true, '3': true, '5': true, '7': true, '9': true};
const partialPurposesConsented = {'1': true, '3': true, '5': true, '7': true};
const tcfConsented = { gdprApplies: true, vendorData: { vendor: { consents: vendorConsented }, purpose: { consents: purposesConsented } } };
const tcfVendorNotConsented = { gdprApplies: true, vendorData: { purpose: {consents: purposesConsented} } };
const tcfPurposesNotConsented = { gdprApplies: true, vendorData: { vendor: { consents: vendorConsented } } };
const tcfPartialPurposesNotConsented = { gdprApplies: true, vendorData: { vendor: { consents: vendorConsented }, purpose: { consents: partialPurposesConsented } } };

[
['not applicable', tcfGDPRNotApplicable, true],
['tcf consented', tcfConsented, true],
['tcf vendor not consented', tcfVendorNotConsented, false],
['tcf purposes not consented', tcfPurposesNotConsented, false],
['tcp partial purposes not consented', tcfPartialPurposesNotConsented, false],
].forEach(([info, gdpr, expected]) => {
it(`for ${info} should return ${expected}`, () => {
expect(azerionedgeRTD.hasGDPRAccess({gdpr})).to.equal(expected);
});

it(`for ${info} should load=${expected} the external script`, () => {
azerionedgeRTD.azerionedgeSubmodule.init(dataProvider, {gdpr, usp: uspNotProvided});
expect(loadExternalScript.called).to.equal(expected);
});

describe('for bid request data', function () {
let callbackStub;

beforeEach(function () {
callbackStub = sinon.mock();
azerionedgeRTD.azerionedgeSubmodule.getBidRequestData(reqBidsConfigObj, callbackStub, dataProvider, {gdpr, usp: uspNotProvided});
});

it(`does call=${expected} the local storage looking for audiences`, function () {
expect(storageStub.called).to.equal(expected);
});

it('calls callback always', function () {
expect(callbackStub.called).to.be.true;
});
});
});
});

describe('USP acccess', () => {
const uspMalformed = -1;
const uspNotApplicable = '1---';
const uspUserNotifiedOptedOut = '1YY-';
const uspUserNotifiedNotOptedOut = '1YN-';
const uspUserNotifiedUnknownOptedOut = '1Y--';
const uspUserNotNotifiedOptedOut = '1NY-';
const uspUserNotNotifiedNotOptedOut = '1NN-';
const uspUserNotNotifiedUnknownOptedOut = '1N--';
const uspUserUnknownNotifiedOptedOut = '1-Y-';
const uspUserUnknownNotifiedNotOptedOut = '1-N-';
const uspUserUnknownNotifiedUnknownOptedOut = '1---';

[
['malformed', uspMalformed, true],
['not applicable', uspNotApplicable, true],
['not provided', uspNotProvided, true],
['user notified and opted out', uspUserNotifiedOptedOut, false],
['user notified and not opted out', uspUserNotifiedNotOptedOut, true],
['user notified and unknown opted out', uspUserNotifiedUnknownOptedOut, true],
['user not notified and opted out', uspUserNotNotifiedOptedOut, false],
['user not notified and not opted out', uspUserNotNotifiedNotOptedOut, false],
['user not notified and unknown opted out', uspUserNotNotifiedUnknownOptedOut, false],
['user unknown notified and opted out', uspUserUnknownNotifiedOptedOut, false],
['user unknown notified and not opted out', uspUserUnknownNotifiedNotOptedOut, true],
['user unknown notified and unknown opted out', uspUserUnknownNotifiedUnknownOptedOut, true],
].forEach(([info, usp, expected]) => {
it(`for ${info} should return ${expected}`, () => {
expect(azerionedgeRTD.hasUSPAccess({usp})).to.equal(expected);
});

it(`for ${info} should load=${expected} the external script`, () => {
azerionedgeRTD.azerionedgeSubmodule.init(dataProvider, {gdpr: tcfGDPRNotApplicable, usp});
expect(loadExternalScript.called).to.equal(expected);
});

describe('for bid request data', function () {
let callbackStub;

beforeEach(function () {
callbackStub = sinon.mock();
azerionedgeRTD.azerionedgeSubmodule.getBidRequestData(reqBidsConfigObj, callbackStub, dataProvider, {gdpr: tcfGDPRNotApplicable, usp});
});

it(`does call=${expected} the local storage looking for audiences`, function () {
expect(storageStub.called).to.equal(expected);
});

it('calls callback always', function () {
expect(callbackStub.called).to.be.true;
});
});
});
});

describe('gets audiences', function () {
let callbackStub;

Expand All @@ -111,7 +213,7 @@ describe('Azerion Edge RTD submodule', function () {
);
});

it('does not run apply audiences to bidders', function () {
it('does not apply audiences to bidders', function () {
expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({});
});

Expand Down