Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0",
"@adobe/spacecat-shared-drs-client": "1.12.1",
"@adobe/spacecat-shared-gpt-client": "1.6.23",
"@adobe/spacecat-shared-http-utils": "1.29.1",
"@adobe/spacecat-shared-http-utils": "https://gist.github.com/ravverma/f48ea6bc8baae3d20c4f3812e34d8ae3/raw/686779ad612fba9c7e461fe30ef64d393b186dcc/adobe-spacecat-shared-http-utils-1.29.0%25202.tgz",
"@adobe/spacecat-shared-ims-client": "1.12.7",
"@adobe/spacecat-shared-launchdarkly-client": "1.3.0",
"@adobe/spacecat-shared-rum-api-client": "2.40.13",
Expand Down
60 changes: 50 additions & 10 deletions src/controllers/organizations.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ import { ProjectDto } from '../dto/project.js';
import { SiteDto } from '../dto/site.js';
import AccessControlUtil from '../support/access-control-util.js';
import { CAP_ORG_READ_ALL } from '../routes/capability-constants.js';
import { filterSitesForProductCode, CUSTOMER_VISIBLE_TIERS } from '../support/utils.js';
import {
filterSitesForProductCode,
getEntitledProductCodes,
CUSTOMER_VISIBLE_TIERS,
} from '../support/utils.js';
import {
ensureOrgEntitlement,
resolveProductCode,
} from '../support/tier-provisioning.js';

// Cross-product sites-listing scope (SITES-46454, Phase 1 of multi-product login support).
// See mysticat-architecture/platform/decisions/cross-product-sites-listing-via-client-id-scope.md
const SITES_LIST_CROSS_PRODUCT_SCOPE = 'sites:list:cross_product';
/**
* Organizations controller. Provides methods to create, read, update and delete organizations.
* @param {object} ctx - Context of the request.
Expand Down Expand Up @@ -319,15 +327,47 @@ function OrganizationsController(ctx, env) {
}
}

// Own sites go through the enrollment filter (delegate org's entitlement).
// Delegated sites have already been validated against the target org's entitlement above.
const filteredSites = await filterSitesForProductCode(
context,
organization,
ownSites,
productCode,
accessControlUtil,
);
// Cross-product branch (SITES-46454). When the session JWT carries
// sites:list:cross_product (minted by spacecat-auth-service for allow-listed IMS
// client_ids), widen the per-product filter to a union across every product the
// org is entitled to — preserving today's entitlement, tier-visibility, and
// enrollment gates and dropping only the single-product restriction. Delegated
// sites are NOT touched; their flow above stays product-pinned to x-product.
const authInfo = context?.attributes?.authInfo;
const isCrossProduct = authInfo?.hasScope?.(SITES_LIST_CROSS_PRODUCT_SCOPE) === true;

let filteredSites;
if (isCrossProduct) {
ctx.log.info(`[sites] cross-product listing for org=${organizationId} user=${authInfo?.getProfile?.()?.userId || 'n/a'}`);
const entitledProductCodes = await getEntitledProductCodes(context, organization);
const byId = new Map();
// Sequential (not parallel) so log lines and DB call ordering stay predictable;
// the entitled-product set is small (one entry per SpaceCat product, currently 3).
for (const code of entitledProductCodes) {
// eslint-disable-next-line no-await-in-loop
const perProduct = await filterSitesForProductCode(
context,
organization,
ownSites,
code,
accessControlUtil,
);
for (const s of perProduct) {
byId.set(s.getId(), s);
}
}
filteredSites = [...byId.values()];
} else {
// Own sites go through the enrollment filter (delegate org's entitlement).
// Delegated sites have already been validated against the target org's entitlement above.
filteredSites = await filterSitesForProductCode(
context,
organization,
ownSites,
productCode,
accessControlUtil,
);
}

return ok([...filteredSites, ...delegatedSites].map((site) => SiteDto.toJSON(site)));
};
Expand Down
26 changes: 26 additions & 0 deletions src/support/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,32 @@ export const CUSTOMER_VISIBLE_TIERS = [
EntitlementModel.TIERS.PLG,
];

/**
* Returns the set of product codes the organization currently has a valid entitlement for.
* Iterates over the known PRODUCT_CODES enum and probes TierClient per code; codes whose
* entitlement is absent are filtered out.
*
* Used by the cross-product sites-listing branch (SITES-46454, Phase 1 of multi-product
* login support — see
* mysticat-architecture/platform/decisions/cross-product-sites-listing-via-client-id-scope.md).
* No other caller should need this today.
*
* @param {object} context - Request context (carries dataAccess + log).
* @param {object} organization - The Organization model.
* @returns {Promise<string[]>} Product codes (e.g. ['ASO','LLMO']); empty when the org has
* no entitlements.
*/
export const getEntitledProductCodes = async (context, organization) => {
const productCodes = Object.values(EntitlementModel.PRODUCT_CODES);
const results = await Promise.all(productCodes.map(async (code) => {
const { entitlement } = await TierClient
.createForOrg(context, organization, code)
.checkValidEntitlement();
return isNonEmptyObject(entitlement) ? code : null;
}));
return results.filter(Boolean);
};

export const filterSitesForProductCode = async (
context,
organization,
Expand Down
150 changes: 150 additions & 0 deletions test/controllers/organizations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1696,4 +1696,154 @@ describe('Organizations Controller', () => {
expect(body.map((s) => s.id)).to.include('site1');
});
});

describe('getSitesForOrganization — cross-product scope (SITES-46454)', () => {
const orgId2 = '9033554c-de8a-44ac-a356-09b51af8cc28';
// Real Site model ids from the shared fixture (lines 41/49/57).
const SITE1_ID = 'site1';
const SITE2_ID = 'site2';
const SITE3_ID = '550e8400-e29b-41d4-a716-446655440001';
let tierClientByProduct;

/**
* Returns a per-product TierClient instance whose entitlement reflects the
* `entitled` argument (true → an Entitlement object on FREE_TRIAL; false → null).
*/
function makePerProductTier(productCode, { entitled, tier = 'FREE_TRIAL' }) {
const entitlement = entitled
? {
getId: () => `entitlement-${productCode}`,
getProductCode: () => productCode,
getTier: () => tier,
}
: null;
return {
checkValidEntitlement: sinon.stub().resolves({ entitlement }),
};
}

beforeEach(() => {
// Cross-product scope present on the session
context.attributes.authInfo
.withScopes([{ name: 'sites:list:cross_product' }])
.withProfile({ is_admin: true, userId: 'preflight-user' });

mockDataAccess.Site.allByOrganizationId.resolves(sites);
mockDataAccess.Organization.findById.resolves(organizations[0]);

// Default: only ASO is entitled; LLMO/ACO not entitled.
tierClientByProduct = {
ASO: makePerProductTier('ASO', { entitled: true }),
LLMO: makePerProductTier('LLMO', { entitled: false }),
ACO: makePerProductTier('ACO', { entitled: false }),
};
sandbox.stub(TierClient, 'createForOrg').callsFake((_ctx, _org, code) => (
tierClientByProduct[code] ?? makePerProductTier(code, { entitled: false })
));

// Default enrollment: only site1 enrolled under ASO.
mockDataAccess.SiteEnrollment.allByEntitlementId = sinon.stub().callsFake((entId) => {
if (entId === 'entitlement-ASO') {
return Promise.resolve([{ getSiteId: () => SITE1_ID }]);
}
return Promise.resolve([]);
});

// No delegation in these tests — keep the SiteImsOrgAccess path inert.
mockDataAccess.SiteImsOrgAccess = {
allByOrganizationIdWithSites: sinon.stub().resolves([]),
};
organizationsController = OrganizationsController(context, env);
});

it('returns only sites enrolled under products the org is entitled to', async () => {
const result = await organizationsController.getSitesForOrganization({
params: { organizationId: orgId2 },
...context,
});
const body = await result.json();

expect(result.status).to.equal(200);
expect(body.map((s) => s.id)).to.have.members([SITE1_ID]);
expect(context.log.info).to.have.been.calledWithMatch(/cross-product listing for org=/);
});

it('unions sites across multiple entitled products and dedupes by site id', async () => {
// Both ASO and LLMO entitled now.
tierClientByProduct.LLMO = makePerProductTier('LLMO', { entitled: true });
// site2 enrolled under both ASO and LLMO so dedupe is exercised; site3 only under LLMO.
mockDataAccess.SiteEnrollment.allByEntitlementId = sinon.stub().callsFake((entId) => {
if (entId === 'entitlement-ASO') {
return Promise.resolve([
{ getSiteId: () => SITE1_ID },
{ getSiteId: () => SITE2_ID },
]);
}
if (entId === 'entitlement-LLMO') {
return Promise.resolve([
{ getSiteId: () => SITE2_ID },
{ getSiteId: () => SITE3_ID },
]);
}
return Promise.resolve([]);
});

const result = await organizationsController.getSitesForOrganization({
params: { organizationId: orgId2 },
...context,
});
const body = await result.json();

expect(result.status).to.equal(200);
expect(body.map((s) => s.id).sort()).to.eql([SITE3_ID, SITE1_ID, SITE2_ID].sort());
});

it('returns empty array when the org has no entitlements at all', async () => {
tierClientByProduct.ASO = makePerProductTier('ASO', { entitled: false });

const result = await organizationsController.getSitesForOrganization({
params: { organizationId: orgId2 },
...context,
});
const body = await result.json();

expect(result.status).to.equal(200);
expect(body).to.eql([]);
});

it('hides PRE_ONBOARD-tier entitlements from non-admin callers (tier-visibility gate preserved)', async () => {
context.attributes.authInfo
.withScopes([{ name: 'sites:list:cross_product' }])
.withProfile({ is_admin: false, userId: 'preflight-user' });
tierClientByProduct.ASO = makePerProductTier('ASO', { entitled: true, tier: 'PRE_ONBOARD' });
// Non-admin needs accessControlUtil.hasAccess(organization) to pass.
sandbox.stub(AccessControlUtil.prototype, 'hasAccess').resolves(true);
organizationsController = OrganizationsController(context, env);

const result = await organizationsController.getSitesForOrganization({
params: { organizationId: orgId2 },
...context,
});
const body = await result.json();

expect(result.status).to.equal(200);
expect(body).to.eql([]);
});

it('falls back to single-product behaviour when the scope is absent', async () => {
context.attributes.authInfo.withScopes([{ name: 'admin' }]);
context.pathInfo = { headers: { 'x-product': 'ASO' } };
organizationsController = OrganizationsController(context, env);

const result = await organizationsController.getSitesForOrganization({
params: { organizationId: orgId2 },
...context,
});
const body = await result.json();

expect(result.status).to.equal(200);
expect(body.map((s) => s.id)).to.have.members([SITE1_ID]);
expect(context.log.info).to.not.have.been.calledWithMatch(/cross-product listing/);
});
});
});
48 changes: 48 additions & 0 deletions test/support/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getIsSummitPlgEnabled,
getCookieValue,
filterSitesForProductCode,
getEntitledProductCodes,
queueDetectCdnAudit,
queueDeliveryConfigWriter,
validateSiteForRedirects,
Expand Down Expand Up @@ -874,6 +875,53 @@ describe('utils', () => {
});
});

describe('getEntitledProductCodes (SITES-46454)', () => {
let sandbox3;
let perProductTiers;
let mockContext;
let mockOrg;

beforeEach(() => {
sandbox3 = sinon.createSandbox();
perProductTiers = {};
sandbox3.stub(TierClient, 'createForOrg').callsFake((_ctx, _org, code) => (
perProductTiers[code] ?? {
checkValidEntitlement: () => Promise.resolve({ entitlement: null }),
}
));
mockContext = { log: { error: sinon.stub() } };
mockOrg = { getId: () => 'org-1' };
});

afterEach(() => {
sandbox3.restore();
});

function mockEntitled(code) {
perProductTiers[code] = {
checkValidEntitlement: () => Promise.resolve({ entitlement: { getId: () => `ent-${code}` } }),
};
}

it('returns only product codes the org has an entitlement for', async () => {
mockEntitled('ASO');
const result = await getEntitledProductCodes(mockContext, mockOrg);
expect(result).to.eql(['ASO']);
});

it('returns multiple codes when org is entitled to several products', async () => {
mockEntitled('ASO');
mockEntitled('LLMO');
const result = await getEntitledProductCodes(mockContext, mockOrg);
expect(result.sort()).to.eql(['ASO', 'LLMO'].sort());
});

it('returns empty array when the org has no entitlements at all', async () => {
const result = await getEntitledProductCodes(mockContext, mockOrg);
expect(result).to.eql([]);
});
});

describe('validateSiteForRedirects', () => {
function makeSite({
id = 'site-1',
Expand Down
Loading