Skip to content
Open
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
10 changes: 8 additions & 2 deletions docs/openapi/sites-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,11 @@ sites-resolve:
internal callers, PLG-wizard-triggering resolveStatuses
(`no_entitlement_for_product`, `aso_pre_onboard`) are remapped to
`site_not_enrolled` so the UI shows "No site onboarded" instead of the PLG
onboarding wizard.
onboarding wizard. Additionally, when `callerImsOrg` differs from the target
org's imsOrgId (a cross-org caller), the `asoTier` field reports the *caller*
org's ASO tier instead of the target org's, so the UI gates on the shell's own
tier. This override applies only when the caller org exists; otherwise the
target org's tier is used.
schema:
$ref: './schemas.yaml#/ImsOrganizationId'
responses:
Expand All @@ -473,7 +477,9 @@ sites-resolve:
description: >-
The resolved site's organization's ASO entitlement tier
(FREE_TRIAL, PAID, PLG, PRE_ONBOARD), or null if no ASO
entitlement exists.
entitlement exists. When `callerImsOrg` is supplied and differs
from the target org's imsOrgId, this reports the caller org's ASO
tier instead (see the callerImsOrg parameter).
example:
data:
organization:
Expand Down
124 changes: 86 additions & 38 deletions src/controllers/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,10 @@ function SitesController(ctx, log, env) {
* { message, resolveStatus, details?, asoTier }
* `asoTier` is included on every response (success and failure) so the UI can gate
* behavior off the org's raw ASO entitlement tier even when resolution fails.
* When `callerImsOrg` is present and differs from the target org's imsOrgId (a cross-org
* caller — e.g. an AEC shell in org A viewing org B), `asoTier` reports the *caller* org's
* tier instead of the target org's, so the UI gates on the shell's own tier. This applies
* only when the caller org exists in the DB; otherwise the target org's tier is used.
* where `resolveStatus` is one of:
* - 'no_entitlement_for_product' — org has no entitlement for the requested x-product.
* - 'aso_pre_onboard' — entitlement tier is not in CUSTOMER_VISIBLE_TIERS (e.g. PRE_ONBOARD).
Expand Down Expand Up @@ -1867,6 +1871,37 @@ function SitesController(ctx, log, env) {
{ 'x-error': message },
);

// callerImsOrg identifies the *caller* (the org their AEC shell is currently in),
// independent of which org's data is being requested via organizationId/imsOrg. It is
// translated to a Spacecat UUID (callerOrgId) once, up front, in the block below.
let callerIsInternal = false;
let callerOrgId = null;

// Cross-org caller: when callerImsOrg identifies a *different* org than the one whose
// data is requested (organizationId/imsOrg/siteId), the UI gates on the caller's own
// ASO tier — the org their AEC shell is in — not the target org's. So every asoTier
// field (success and failure) reports the caller org's tier instead of the target's.
// Only applies when the caller org exists in the DB; otherwise its tier is unknowable
// and we fall back to the target org's tier (isCrossOrgCaller returns false).
let callerAsoTierResolved = false;
let callerAsoTierValue = null;
const getCallerAsoTier = async () => {
if (!callerAsoTierResolved) {
callerAsoTierValue = callerOrgId ? await getAsoTier(callerOrgId, context) : null;
callerAsoTierResolved = true;
}
return callerAsoTierValue;
};
const isCrossOrgCaller = (targetOrg) => Boolean(
callerOrgId && hasText(callerImsOrg) && targetOrg?.getImsOrgId?.() !== callerImsOrg,
);
// Applies the cross-org caller asoTier override to an already-built resolve payload.
const applyCallerAsoTier = async (data, targetOrg) => (
data && isCrossOrgCaller(targetOrg)
? { ...data, asoTier: await getCallerAsoTier() }
: data
);

// Resolves the org's ASO tier for a failure response. Reuses the entitlement already
// fetched by the caller only when it's both the ASO entitlement (x-product: ASO) AND
// truthy — TierClient.getFirstEnrollment() nulls out `entitlement` whenever no site is
Expand All @@ -1875,11 +1910,16 @@ function SitesController(ctx, log, env) {
// Falls back to an independent lookup (unambiguous — queries Entitlement directly)
// whenever we can't trust the passed-in value, so asoTier is always populated correctly
// regardless of which product was resolved or which TierClient method fetched it.
const resolveAsoTier = async (orgId, entitlement) => (
productCode === ASO_PRODUCT_CODE && entitlement
const resolveAsoTier = async (targetOrg, entitlement) => {
// Cross-org caller: report the caller org's ASO tier instead of the target org's
// (see isCrossOrgCaller / getCallerAsoTier below).
if (isCrossOrgCaller(targetOrg)) {
return getCallerAsoTier();
}
return productCode === ASO_PRODUCT_CODE && entitlement
? entitlement.getTier()
: getAsoTier(orgId, context)
);
: getAsoTier(targetOrg.getId(), context);
};

const isOrgWaitingForIpAllowlisting = async (org) => {
if (productCode !== ASO_PRODUCT_CODE) {
Expand All @@ -1895,17 +1935,16 @@ function SitesController(ctx, log, env) {
}
};

// callerImsOrg identifies the *caller* (the org their AEC shell is currently in),
// independent of which org's data is being requested via organizationId/imsOrg.
// We translate it to a Spacecat UUID once, up front, so the per-path remap can
// decide whether the caller is an internal/demo org listed in ASO_PLG_EXCLUDED_ORGS.
let callerIsInternal = false;
// Translate callerImsOrg to a Spacecat UUID (callerOrgId) so the per-path remap can
// decide whether the caller is an internal/demo org listed in ASO_PLG_EXCLUDED_ORGS,
// and so cross-org asoTier override (above) can look up the caller org's tier.
if (hasText(callerImsOrg)) {
try {
const callerOrg = await Organization.findByImsOrgId(callerImsOrg);
if (callerOrg) {
callerIsInternal = isInternalOrg(callerOrg.getId(), context.env);
log.info(`[resolveSite] callerOrg UUID=${callerOrg.getId()} callerIsInternal=${callerIsInternal}`);
callerOrgId = callerOrg.getId();
callerIsInternal = isInternalOrg(callerOrgId, context.env);
log.info(`[resolveSite] callerOrg UUID=${callerOrgId} callerIsInternal=${callerIsInternal}`);
} else {
log.info(`[resolveSite] callerImsOrg=${callerImsOrg} not found in DB`);
}
Expand Down Expand Up @@ -1954,7 +1993,7 @@ function SitesController(ctx, log, env) {
// Check admin-configured default site first; only use it if the caller can view it.
const defaultData = await resolveOrgDefaultSite(...args);
if (defaultData && viewable.has(defaultData.site.id)) {
return ok({ data: defaultData });
return ok({ data: await applyCallerAsoTier(defaultData, org) });
}

// Scan all enrolled sites and return the first one the caller can view.
Expand All @@ -1966,7 +2005,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
callerIsInternal ? 'site_not_enrolled' : 'no_entitlement_for_product',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}

Expand All @@ -1976,7 +2015,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'aso_pre_onboard',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}
log.info(`[resolveSite] Internal or admin caller (callerImsOrg=${callerImsOrg}): skipping tier check (tier=${entitlement.getTier()})`, failureDetails);
Expand All @@ -1988,7 +2027,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'site_not_enrolled',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}
const firstViewableSite = await Site.findById(firstViewableEnrollment.getSiteId());
Expand All @@ -1997,23 +2036,26 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'site_not_enrolled',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}
return ok({
data: await buildResolveData(
data: await applyCallerAsoTier(
await buildResolveData(
org,
firstViewableSite,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
org,
firstViewableSite,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
});
}

// Non-FACS path (admin / internal / JWT federal grant / LD-off): existing logic.
const defaultData = await resolveOrgDefaultSite(...args);
if (defaultData) {
return ok({ data: defaultData });
return ok({ data: await applyCallerAsoTier(defaultData, org) });
}

const tierClient = TierClient.createForOrg(context, org, productCode);
Expand All @@ -2031,7 +2073,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'no_entitlement_for_product',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}

Expand All @@ -2041,7 +2083,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
resolveStatus,
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}

Expand All @@ -2051,7 +2093,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'aso_pre_onboard',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
}
log.info(`[resolveSite] Internal or admin caller (callerImsOrg=${callerImsOrg}): skipping tier check (tier=${entitlement.getTier()})`, failureDetails);
Expand All @@ -2060,11 +2102,14 @@ function SitesController(ctx, log, env) {
if (enrolledSite && (accessControlUtil.hasAdminAccess()
|| CUSTOMER_VISIBLE_TIERS.includes(entitlement.getTier()))) {
return ok({
data: await buildResolveData(
data: await applyCallerAsoTier(
await buildResolveData(
org,
enrolledSite,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
org,
enrolledSite,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
});
}
Expand All @@ -2073,7 +2118,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'site_not_enrolled',
failureDetails,
await resolveAsoTier(org.getId(), entitlement),
await resolveAsoTier(org, entitlement),
);
};

Expand Down Expand Up @@ -2116,7 +2161,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'no_entitlement_for_product',
failureDetails,
await resolveAsoTier(orgId, entitlement),
await resolveAsoTier(organization, entitlement),
);
}

Expand All @@ -2129,7 +2174,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
resolveStatus,
failureDetails,
await resolveAsoTier(orgId, entitlement),
await resolveAsoTier(organization, entitlement),
);
}

Expand All @@ -2139,7 +2184,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'aso_pre_onboard',
failureDetails,
await resolveAsoTier(orgId, entitlement),
await resolveAsoTier(organization, entitlement),
);
}
log.info(`[resolveSite] Internal caller (callerImsOrg=${callerImsOrg}): skipping tier check (tier=${entitlement.getTier()}), letting enrollment decide for siteId=${siteId}`);
Expand All @@ -2150,7 +2195,7 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'site_not_enrolled',
failureDetails,
await resolveAsoTier(orgId, entitlement),
await resolveAsoTier(organization, entitlement),
);
}

Expand All @@ -2173,17 +2218,20 @@ function SitesController(ctx, log, env) {
'No site found for the provided parameters',
'site_not_enrolled',
failureDetails,
await resolveAsoTier(orgId, entitlement),
await resolveAsoTier(organization, entitlement),
);
}
}

return ok({
data: await buildResolveData(
data: await applyCallerAsoTier(
await buildResolveData(
organization,
site,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
organization,
site,
context,
productCode === ASO_PRODUCT_CODE ? entitlement : undefined,
),
});
}
Expand Down
87 changes: 87 additions & 0 deletions test/controllers/sites.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7049,6 +7049,93 @@ describe('Sites Controller', () => {
expect(mockDataAccess.Organization.findByImsOrgId).to.have.been.calledWith('nonexistent@AdobeOrg');
});

describe('cross-org callerImsOrg asoTier override', () => {
// target = testOrganizations[0] (ims 1234...); caller = testOrganizations[1] (ims 2234...)
const targetId = '9033554c-de8a-44ac-a356-09b51af8cc28';
const callerId = '5f3b3626-029c-476e-924b-0c1bba2e871f';
const callerIms = '2234567890ABCDEF12345678@AdobeOrg';

it('reports the caller org ASO tier on success when callerImsOrg differs from the target org', async () => {
context.data = { organizationId: targetId, callerImsOrg: callerIms };
mockDataAccess.Organization.findById.resolves(testOrganizations[0]);
mockDataAccess.Organization.findByImsOrgId
.withArgs(callerIms).resolves(testOrganizations[1]);
mockDataAccess.Site.findById.resolves(testSites[0]);
mockTierClientStub.getFirstEnrollment.resolves({
entitlement: { getTier: () => 'FREE_TRIAL' },
site: testSites[0],
});
// target tier PLG, caller tier FREE_TRIAL — asoTier must reflect the caller.
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode
.withArgs(targetId).resolves({ getTier: () => 'PLG' });
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode
.withArgs(callerId).resolves({ getTier: () => 'FREE_TRIAL' });

const response = await sitesController.resolveSite(context);

expect(response.status).to.equal(200);
const body = await response.json();
expect(body.data).to.have.property('asoTier', 'FREE_TRIAL');
// isSummitPlgEnabled stays derived from the *target* org (PLG), only asoTier is overridden.
expect(body.data).to.have.property('isSummitPlgEnabled', true);
});

it('reports the caller org ASO tier on a failure response', async () => {
context.data = { organizationId: targetId, callerImsOrg: callerIms };
mockDataAccess.Organization.findById.resolves(testOrganizations[0]);
mockDataAccess.Organization.findByImsOrgId
.withArgs(callerIms).resolves(testOrganizations[1]);
mockTierClientStub.getFirstEnrollment.resolves({ entitlement: null, site: null });
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode
.withArgs(callerId).resolves({ getTier: () => 'PAID' });

const response = await sitesController.resolveSite(context);

expect(response.status).to.equal(404);
const body = await response.json();
expect(body.asoTier).to.equal('PAID');
});

it('falls back to the target org ASO tier when the caller org is not in the DB', async () => {
context.data = { organizationId: targetId, callerImsOrg: 'ghost@AdobeOrg' };
mockDataAccess.Organization.findById.resolves(testOrganizations[0]);
mockDataAccess.Organization.findByImsOrgId.withArgs('ghost@AdobeOrg').resolves(null);
mockDataAccess.Site.findById.resolves(testSites[0]);
mockTierClientStub.getFirstEnrollment.resolves({
entitlement: { getTier: () => 'FREE_TRIAL' },
site: testSites[0],
});
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode
.withArgs(targetId).resolves({ getTier: () => 'PLG' });

const response = await sitesController.resolveSite(context);

expect(response.status).to.equal(200);
const body = await response.json();
expect(body.data).to.have.property('asoTier', 'PLG');
});

it('does not override when callerImsOrg matches the target org imsOrgId', async () => {
const sameIms = testOrganizations[0].getImsOrgId();
context.data = { organizationId: targetId, callerImsOrg: sameIms };
mockDataAccess.Organization.findById.resolves(testOrganizations[0]);
mockDataAccess.Organization.findByImsOrgId.withArgs(sameIms).resolves(testOrganizations[0]);
mockDataAccess.Site.findById.resolves(testSites[0]);
mockTierClientStub.getFirstEnrollment.resolves({
entitlement: { getTier: () => 'FREE_TRIAL' },
site: testSites[0],
});
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode
.withArgs(targetId).resolves({ getTier: () => 'PLG' });

const response = await sitesController.resolveSite(context);

expect(response.status).to.equal(200);
const body = await response.json();
expect(body.data).to.have.property('asoTier', 'PLG');
});
});

it('should return 404 with site_not_enrolled when imsOrg not in DB and caller is internal', async () => {
const internalUuid = '9033554c-de8a-44ac-a356-09b51af8cc28';
const internalIms = '1234567890ABCDEF12345678@AdobeOrg';
Expand Down
Loading