diff --git a/docs/openapi/sites-api.yaml b/docs/openapi/sites-api.yaml index 0576704434..a74a9365f1 100644 --- a/docs/openapi/sites-api.yaml +++ b/docs/openapi/sites-api.yaml @@ -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: @@ -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: diff --git a/src/controllers/sites.js b/src/controllers/sites.js index 782a16202a..c25748686b 100755 --- a/src/controllers/sites.js +++ b/src/controllers/sites.js @@ -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). @@ -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 @@ -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) { @@ -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`); } @@ -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. @@ -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), ); } @@ -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); @@ -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()); @@ -1997,15 +2036,18 @@ 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, ), }); } @@ -2013,7 +2055,7 @@ function SitesController(ctx, log, env) { // 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); @@ -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), ); } @@ -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), ); } @@ -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); @@ -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, ), }); } @@ -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), ); }; @@ -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), ); } @@ -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), ); } @@ -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}`); @@ -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), ); } @@ -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, ), }); } diff --git a/test/controllers/sites.test.js b/test/controllers/sites.test.js index 5deb4edd50..fccace9ad5 100644 --- a/test/controllers/sites.test.js +++ b/test/controllers/sites.test.js @@ -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';