diff --git a/src/controllers/agentic-rules-factory.js b/src/controllers/agentic-rules-factory.js index 159e44ce8..adee19fda 100644 --- a/src/controllers/agentic-rules-factory.js +++ b/src/controllers/agentic-rules-factory.js @@ -191,8 +191,10 @@ export function createRulesController({ tableName, dimensionLabel }) { if (!await ac.hasAccess(site)) { return { error: forbidden(`Only users belonging to the organization can manage ${dimensionLabel} rules`) }; } - if (requireAdmin && !ac.isLLMOAdministrator()) { - return { error: forbidden(`Only LLMO administrators can modify ${dimensionLabel} rules`) }; + if (requireAdmin && !await ac.hasLlmoCapabilityForSite(site)) { + return { + error: forbidden(ac.llmoForbiddenMessage(`Only LLMO administrators can modify ${dimensionLabel} rules`)), + }; } return { siteId, client }; } diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index e8031e726..d32b432b0 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -522,8 +522,8 @@ function LlmoController(ctx) { return siteValidation; } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the LLMO config'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(siteValidation.site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update the LLMO config')); } // Support gzip-compressed request bodies (Content-Type: application/gzip) @@ -624,8 +624,8 @@ function LlmoController(ctx) { return siteValidation; } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can add questions'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(siteValidation.site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can add questions')); } const { site, config } = siteValidation; @@ -673,8 +673,8 @@ function LlmoController(ctx) { return siteValidation; } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can remove questions'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(siteValidation.site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can remove questions')); } const { site, config } = siteValidation; @@ -699,8 +699,8 @@ function LlmoController(ctx) { return siteValidation; } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update questions'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(siteValidation.site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update questions')); } const { site, config } = siteValidation; @@ -739,8 +739,8 @@ function LlmoController(ctx) { return siteValidation; } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can add customer intent'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(siteValidation.site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can add customer intent')); } const { site, config } = siteValidation; @@ -856,8 +856,8 @@ function LlmoController(ctx) { } const { site, config } = siteValidation; - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the CDN logs filter'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update the CDN logs filter')); } if (!isObject(data)) { @@ -890,8 +890,8 @@ function LlmoController(ctx) { } const { site, config } = siteValidation; - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the CDN bucket config'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update the CDN bucket config')); } if (!isObject(data)) { @@ -940,8 +940,8 @@ function LlmoController(ctx) { const { data } = context; try { - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can onboard'); + if (!accessControlUtil.hasLlmoAdminCapability()) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can onboard')); } // Validate required fields @@ -1486,8 +1486,8 @@ function LlmoController(ctx) { return forbidden('User does not have access to this site'); } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the edge optimize config'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update the edge optimize config')); } if (!await accessControlUtil.isOwnerOfSite(site)) { @@ -1800,8 +1800,8 @@ function LlmoController(ctx) { return forbidden('User does not have access to this site'); } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can get the edge optimize config'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can get the edge optimize config')); } const baseURL = site.getBaseURL(); @@ -2049,8 +2049,8 @@ function LlmoController(ctx) { return forbidden('User does not have access to this site'); } - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can add staging domains'); + if (!await accessControlUtil.hasLlmoCapabilityForSite(site)) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can add staging domains')); } if (!areDomainsSameAsBase(stagingDomains, site.getBaseURL())) { @@ -2133,8 +2133,8 @@ function LlmoController(ctx) { const { data } = context; try { - if (!accessControlUtil.isLLMOAdministrator()) { - return forbidden('Only LLMO administrators can update the query index'); + if (!accessControlUtil.hasLlmoAdminCapability()) { + return forbidden(accessControlUtil.llmoForbiddenMessage('Only LLMO administrators can update the query index')); } if (!data || typeof data !== 'object') { diff --git a/src/support/access-control-util.js b/src/support/access-control-util.js index 949ca0718..f6aa00d72 100644 --- a/src/support/access-control-util.js +++ b/src/support/access-control-util.js @@ -19,8 +19,12 @@ import { import TierClient from '@adobe/spacecat-shared-tier-client'; import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js'; +import { resolveRouteCapability } from '@adobe/spacecat-shared-http-utils/src/auth/route-utils.js'; import { UnauthorizedProductError } from './errors.js'; import { CUSTOMER_VISIBLE_TIERS } from './utils.js'; +import { listBrandIdsForSite } from './brands-storage.js'; +import { listResourceIdsWithCapability } from './state-access-mapping-utils.js'; +import routeFacsCapabilities from '../routes/facs-capabilities.js'; const ANONYMOUS_ENDPOINTS = [ /^GET \/slack\/events$/, @@ -193,6 +197,144 @@ export default class AccessControlUtil { return this.authInfo.isLLMOAdministrator() && !this._lastAccessWasDelegated; } + /** + * Whether the caller may perform an LLMO admin-equivalent action on a specific + * site under the hybrid FACS model. Two signals decide it: + * - the `facs_enabled` JWT claim (minted at login, present on every path) — + * is the caller's org FACS-enrolled? + * - `context.attributes.facs.enabled` (the wrapper's defer marker, set only + * when the wrapper could not resolve a ReBAC resource) — did the wrapper + * hand enforcement to the controller? + * + * Resolution: + * - Not FACS-enrolled → fall back to the legacy `isLLMOAdministrator()` claim + * (dual-running: legacy stays authoritative until the org migrates). + * - Enrolled AND not deferred → the wrapper already confirmed the route's + * capability upstream (JWT short-circuit or state-layer admit) → allow. + * - Enrolled AND deferred → the wrapper could not map the site to its LLMO + * ReBAC `brand`, so authorize here: the caller must hold the route's + * required capability via a state-layer grant on any brand linked to the + * site. Fail closed if the state layer is unreachable. + * + * The required capability is NOT passed in — it is derived from the same + * `routeFacsCapabilities.PRODUCTS_ROUTES` map the wrapper enforced (via + * `resolveRouteCapability` on the current request), so the controller and the + * wrapper can never disagree and there is no hardcoded capability to drift. + * + * MUST only be called on FACS-governed routes (the wrapper runs ahead of the + * controller); on a non-governed route the "not deferred" branch would admit + * any enrolled caller. + * + * @param {object} site - Site model (exposes getId + getOrganizationId). + * @returns {Promise} + */ + async hasLlmoCapabilityForSite(site) { + const facsEnabled = this.authInfo.getProfile?.()?.facs_enabled === true; + if (!facsEnabled) { + return this.isLLMOAdministrator(); + } + + const facs = this.context.attributes?.facs; + if (!facs?.enabled) { + // Enrolled and the wrapper already confirmed the capability upstream. + return true; + } + + // Derive the route's required capability from the same map the wrapper used. + const routeMap = routeFacsCapabilities.PRODUCTS_ROUTES?.[facs.product?.toUpperCase?.()]; + const capability = routeMap ? resolveRouteCapability(this.context, routeMap) : null; + if (!capability) { + this.log?.warn?.('[acl] FACS deferred but no route capability resolved - denying'); + return false; + } + + // Deferred: resolve the site's brand(s) and check the state-layer grant. + const postgrestClient = this.context.dataAccess?.services?.postgrestClient; + if (!postgrestClient?.from) { + this.log?.warn?.('[acl] FACS deferred but postgrestClient unavailable - denying'); + return false; + } + + const orgId = site.getOrganizationId(); + const org = await this.context.dataAccess.Organization.findById(orgId); + const imsOrgId = org?.getImsOrgId?.(); + if (!hasText(imsOrgId)) { + return false; + } + + const brandIds = await listBrandIdsForSite(orgId, site.getId(), postgrestClient); + if (brandIds.size === 0) { + return false; + } + + const capable = await listResourceIdsWithCapability(postgrestClient, { + imsOrgId, + product: facs.product, + resourceType: 'brand', + subjectId: facs.subjectId, + capability, + }); + for (const id of brandIds) { + if (capable.has(id)) { + return true; + } + } + return false; + } + + /** + * Org-level counterpart of {@link hasLlmoCapabilityForSite} for FACS-governed + * routes with no site (e.g. `POST /llmo/onboard`). The wrapper enforces the + * route's capability upstream, so once enrolled the only question is whether + * it confirmed: + * - not enrolled → legacy `isLLMOAdministrator()` + * - enrolled, not deferred → wrapper confirmed the org-wide capability → allow + * - enrolled, deferred → wrapper could not confirm (caller lacks the + * org-wide grant and there is no resource to bind against) → deny + * + * MUST only be called on FACS-governed routes (see {@link hasLlmoCapabilityForSite}). + * + * @returns {boolean} + */ + hasLlmoAdminCapability() { + const facsEnabled = this.authInfo.getProfile?.()?.facs_enabled === true; + if (!facsEnabled) { + return this.isLLMOAdministrator(); + } + return !this.context.attributes?.facs?.enabled; + } + + /** + * Chooses the 403 message for an LLMO capability denial based on which layer + * rejected the caller, so the response is not misleading: + * - org NOT FACS-enrolled → the legacy `isLLMOAdministrator()` claim denied; + * keep the caller-supplied legacy wording ("Only LLMO administrators …"). + * - org FACS-enrolled → the denial came from the hybrid FACS/ReBAC layer + * (the caller lacks the route's required capability on the resource), so + * "administrator" is misleading — return a capability-oriented message + * naming the capability derived from the same route map the wrapper used. + * + * Pair with {@link hasLlmoCapabilityForSite} / {@link hasLlmoAdminCapability}: + * if (!await ac.hasLlmoCapabilityForSite(site)) { + * return forbidden(ac.llmoForbiddenMessage('Only LLMO administrators can …')); + * } + * + * @param {string} legacyMessage - wording used on the legacy (non-FACS) path. + * @returns {string} + */ + llmoForbiddenMessage(legacyMessage) { + const facsEnabled = this.authInfo.getProfile?.()?.facs_enabled === true; + if (!facsEnabled) { + return legacyMessage; + } + const product = this.context.attributes?.facs?.product; + const routeMap = routeFacsCapabilities.PRODUCTS_ROUTES?.[product?.toUpperCase?.()]; + const capability = routeMap ? resolveRouteCapability(this.context, routeMap) : null; + return capability + ? `Access denied: you do not hold the required '${capability}' permission for this resource` + : 'Access denied: you do not have the required LLMO permission for this resource'; + } + canManageImsOrgAccess() { if (!this.isAccessTypeIms() && !this.isAccessTypeJWT()) { return false; diff --git a/src/support/brands-storage.js b/src/support/brands-storage.js index fd4b27bc1..b46c550a6 100644 --- a/src/support/brands-storage.js +++ b/src/support/brands-storage.js @@ -834,6 +834,53 @@ export async function isSemrushMarketMirrorSite(organizationId, siteId, postgres return Array.isArray(data) && data.length > 0; } +/** + * Lightweight lookup of every brand id linked to a site within an org — the + * union of the brand whose OWN primary site this is (`brands.site_id`) and any + * brand that lists it via `brand_sites`. Used by resource-aware authorization + * (e.g. `AccessControlUtil.hasLlmoCapabilityForSite`) to map a `:siteId` route + * to the LLMO ReBAC `brand` resource(s), then check state-layer grants on them. + * + * Selects ids only (no child-table joins) — cheaper than `getBrandBySite`, and + * returns all linked brands rather than the single primary one. + * + * @param {string} organizationId - SpaceCat organization UUID + * @param {string} siteId - Site UUID + * @param {object} postgrestClient - PostgREST client + * @returns {Promise>} brand ids linked to the site (empty when none) + */ +export async function listBrandIdsForSite(organizationId, siteId, postgrestClient) { + if (!postgrestClient?.from || !hasText(organizationId) || !hasText(siteId)) { + return new Set(); + } + + const [ownRes, linkedRes] = await Promise.all([ + postgrestClient + .from('brands') + .select('id') + .eq('organization_id', organizationId) + .eq('status', 'active') + .eq('site_id', siteId), + postgrestClient + .from('brand_sites') + .select('brand_id') + .eq('organization_id', organizationId) + .eq('site_id', siteId), + ]); + + if (ownRes.error) { + throw new Error(`Failed to resolve brands for site: ${ownRes.error.message}`); + } + if (linkedRes.error) { + throw new Error(`Failed to resolve brand-site links for site: ${linkedRes.error.message}`); + } + + const ids = new Set(); + (ownRes.data || []).forEach((row) => hasText(row.id) && ids.add(row.id)); + (linkedRes.data || []).forEach((row) => hasText(row.brand_id) && ids.add(row.brand_id)); + return ids; +} + /** * Creates or updates a brand in the normalized brands table, * including all nested child tables (aliases, competitors, social, earned, sites). diff --git a/src/support/state-access-mapping-utils.js b/src/support/state-access-mapping-utils.js index 32e5740c3..205071a0e 100644 --- a/src/support/state-access-mapping-utils.js +++ b/src/support/state-access-mapping-utils.js @@ -142,11 +142,12 @@ export async function listFacsAccessMappings(postgrestClient, filters = {}) { } /** - * Builds the set of `resource_id`s the caller may **view** for a product + - * resource type — the union of org-scoped and user-scoped active bindings whose - * `granted_capabilities` include `/can_view`. Used by ReBAC-filtered - * collection endpoints (list-sites, list-brands) to narrow results to the - * resources a resource-scoped caller can see. + * Builds the set of `resource_id`s on which the caller holds a specific + * `/` for a product + resource type — the union of + * org-scoped and user-scoped active bindings whose `granted_capabilities` + * include `capability`. Resource-aware controllers authorize a write by + * intersecting the returned set with the resources a request touches; + * {@link listViewableResourceIds} is this with `can_view`. * * Org-scoped grants always apply; the user-scoped read runs only when a * `subjectId` is known (a missing subjectId must NOT widen the query — without @@ -158,13 +159,13 @@ export async function listFacsAccessMappings(postgrestClient, filters = {}) { * @param {string} args.imsOrgId - REQUIRED. Canonical caller org id. * @param {string} args.product - REQUIRED. Uppercase product code. * @param {string} args.resourceType - REQUIRED. e.g. 'site' | 'brand'. + * @param {string} args.capability - REQUIRED. Fully-qualified `/`. * @param {string} [args.subjectId] - Caller's canonical user id (JWT sub). - * @returns {Promise>} resource_ids the caller may view. + * @returns {Promise>} resource_ids on which the caller holds `capability`. */ -export async function listViewableResourceIds(postgrestClient, { - imsOrgId, product, resourceType, subjectId, +export async function listResourceIdsWithCapability(postgrestClient, { + imsOrgId, product, resourceType, subjectId, capability, }) { - const viewCapability = `${product.toLowerCase()}/can_view`; const subjectScopes = [{ subjectType: 'org', subjectId: imsOrgId }]; if (subjectId) { subjectScopes.push({ subjectType: 'user', subjectId }); @@ -183,7 +184,7 @@ export async function listViewableResourceIds(postgrestClient, { const ids = new Set(); for (const rows of pages) { for (const row of rows) { - if ((row.granted_capabilities ?? []).includes(viewCapability)) { + if ((row.granted_capabilities ?? []).includes(capability)) { ids.add(row.resource_id); } } @@ -191,6 +192,29 @@ export async function listViewableResourceIds(postgrestClient, { return ids; } +/** + * Builds the set of `resource_id`s the caller may **view** for a product + + * resource type — {@link listResourceIdsWithCapability} specialised to + * `/can_view`. Used by ReBAC-filtered collection endpoints + * (list-sites, list-brands) to narrow results to the resources a + * resource-scoped caller can see. + * + * @param {object} postgrestClient + * @param {object} args + * @param {string} args.imsOrgId - REQUIRED. Canonical caller org id. + * @param {string} args.product - REQUIRED. Uppercase product code. + * @param {string} args.resourceType - REQUIRED. e.g. 'site' | 'brand'. + * @param {string} [args.subjectId] - Caller's canonical user id (JWT sub). + * @returns {Promise>} resource_ids the caller may view. + */ +export async function listViewableResourceIds(postgrestClient, { + imsOrgId, product, resourceType, subjectId, +}) { + return listResourceIdsWithCapability(postgrestClient, { + imsOrgId, product, resourceType, subjectId, capability: `${product.toLowerCase()}/can_view`, + }); +} + /** * Fetches a single **active** binding by primary key, scoped to the caller's * org + product. Used to authorize PATCH / DELETE before mutating: the caller's diff --git a/test/controllers/agentic-categories.test.js b/test/controllers/agentic-categories.test.js index badae0cc6..371b1150e 100644 --- a/test/controllers/agentic-categories.test.js +++ b/test/controllers/agentic-categories.test.js @@ -97,6 +97,10 @@ function loadController( hasAccess: mockHasAccess, hasAdminAccess: sinon.stub().returns(false), isLLMOAdministrator: mockIsAdmin, + // The factory's admin gate is now hasLlmoCapabilityForSite; delegate to the + // same stub so existing mockIsAdmin expectations (allow/deny) carry over. + hasLlmoCapabilityForSite: async () => mockIsAdmin(), + llmoForbiddenMessage: (msg) => msg, }); return { controller: AgenticCategoriesController(), mockHasAccess }; } diff --git a/test/controllers/agentic-page-types.test.js b/test/controllers/agentic-page-types.test.js index 9f5f184cf..c5469708a 100644 --- a/test/controllers/agentic-page-types.test.js +++ b/test/controllers/agentic-page-types.test.js @@ -75,6 +75,8 @@ function loadController(mockHasAccess = sinon.stub().resolves(true)) { hasAccess: mockHasAccess, hasAdminAccess: sinon.stub().returns(false), isLLMOAdministrator: sinon.stub().returns(true), + hasLlmoCapabilityForSite: async () => true, + llmoForbiddenMessage: (msg) => msg, }); return AgenticPageTypesController(); } diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 4453be644..bdd6db857 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -63,6 +63,10 @@ const createMockAccessControlUtil = (accessResult, hasAdminAccessResult = true, hasAccess: async () => accessResult, hasAdminAccess: () => hasAdminAccessResult, isLLMOAdministrator: () => isLLMOAdministratorResult, + // FACS-hybrid gates mirror the legacy admin result so existing expectations hold. + hasLlmoCapabilityForSite: async () => isLLMOAdministratorResult, + llmoForbiddenMessage: (msg) => msg, + hasLlmoAdminCapability: () => isLLMOAdministratorResult, isOwnerOfSite: async () => isOwnerOfSiteResult, }), }); @@ -291,6 +295,9 @@ describe('LlmoController', () => { isLLMOAdministrator() { return true; }, + hasLlmoCapabilityForSite: async () => true, + llmoForbiddenMessage: (msg) => msg, + hasLlmoAdminCapability: () => true, async isOwnerOfSite() { return true; }, @@ -409,6 +416,9 @@ describe('LlmoController', () => { }, hasAdminAccess() { return true; }, isLLMOAdministrator() { return true; }, + hasLlmoCapabilityForSite: async () => true, + llmoForbiddenMessage: (msg) => msg, + hasLlmoAdminCapability: () => true, async isOwnerOfSite() { return true; }, }; }, @@ -450,6 +460,9 @@ describe('LlmoController', () => { async hasAccess() { throw new Error('emailId is required'); }, hasAdminAccess() { return true; }, isLLMOAdministrator() { return true; }, + hasLlmoCapabilityForSite: async () => true, + llmoForbiddenMessage: (msg) => msg, + hasLlmoAdminCapability: () => true, async isOwnerOfSite() { return true; }, }; }, @@ -4987,6 +5000,9 @@ describe('LlmoController', () => { hasAccess: hasAccessStub, hasAdminAccess: () => false, isLLMOAdministrator: () => false, + hasLlmoCapabilityForSite: async () => false, + llmoForbiddenMessage: (msg) => msg, + hasLlmoAdminCapability: () => false, isOwnerOfSite: isOwnerStub, }), }, @@ -5900,9 +5916,9 @@ describe('LlmoController', () => { }); }); // end describe('CDN routing') - it('should call hasAccess before isLLMOAdministrator before isOwnerOfSite', async () => { + it('should call hasAccess before the FACS capability gate before isOwnerOfSite', async () => { const hasAccessStub = sinon.stub().resolves(true); - const isLLMOAdminStub = sinon.stub().returns(true); + const hasCapStub = sinon.stub().resolves(true); const isOwnerStub = sinon.stub().resolves(false); const OrderedController = await esmock('../../../src/controllers/llmo/llmo.js', { @@ -5911,7 +5927,8 @@ describe('LlmoController', () => { fromContext: () => ({ hasAccess: hasAccessStub, hasAdminAccess: () => false, - isLLMOAdministrator: isLLMOAdminStub, + hasLlmoCapabilityForSite: hasCapStub, + llmoForbiddenMessage: (msg) => msg, isOwnerOfSite: isOwnerStub, }), }, @@ -5924,8 +5941,8 @@ describe('LlmoController', () => { .createOrUpdateEdgeConfig(edgeConfigContext); expect(result.status).to.equal(403); - expect(hasAccessStub.calledBefore(isLLMOAdminStub), 'hasAccess must be called before isLLMOAdministrator').to.be.true; - expect(isLLMOAdminStub.calledBefore(isOwnerStub), 'isLLMOAdministrator must be called before isOwnerOfSite').to.be.true; + expect(hasAccessStub.calledBefore(hasCapStub), 'hasAccess must be called before the capability gate').to.be.true; + expect(hasCapStub.calledBefore(isOwnerStub), 'capability gate must be called before isOwnerOfSite').to.be.true; }); it('should fire opt-in notification when site is newly opted', async () => { @@ -6957,9 +6974,9 @@ describe('LlmoController', () => { expect(mockConfig.updateEdgeOptimizeConfig).to.have.been.called; }); - it('should call hasAccess before isLLMOAdministrator', async () => { + it('should call hasAccess before the FACS capability gate', async () => { const hasAccessStub = sinon.stub().resolves(true); - const isLLMOAdminStub = sinon.stub().returns(false); + const hasCapStub = sinon.stub().resolves(false); const OrderedController = await esmock('../../../src/controllers/llmo/llmo.js', { '../../../src/support/access-control-util.js': { @@ -6967,7 +6984,8 @@ describe('LlmoController', () => { fromContext: () => ({ hasAccess: hasAccessStub, hasAdminAccess: () => false, - isLLMOAdministrator: isLLMOAdminStub, + hasLlmoCapabilityForSite: hasCapStub, + llmoForbiddenMessage: (msg) => msg, isOwnerOfSite: sinon.stub().resolves(true), }), }, @@ -6980,7 +6998,7 @@ describe('LlmoController', () => { .createOrUpdateStageEdgeConfig(stageConfigContext); expect(result.status).to.equal(403); - expect(hasAccessStub.calledBefore(isLLMOAdminStub), 'hasAccess must be called before isLLMOAdministrator').to.be.true; + expect(hasAccessStub.calledBefore(hasCapStub), 'hasAccess must be called before the capability gate').to.be.true; }); }); diff --git a/test/controllers/suggestions.test.js b/test/controllers/suggestions.test.js index ef7784730..b774843af 100644 --- a/test/controllers/suggestions.test.js +++ b/test/controllers/suggestions.test.js @@ -12990,6 +12990,8 @@ describe('Suggestions Controller', () => { fromContext: () => ({ hasAccess: sandbox.stub().resolves(true), isLLMOAdministrator: sandbox.stub().returns(true), + hasLlmoCapabilityForSite: sandbox.stub().resolves(true), + llmoForbiddenMessage: (msg) => msg, isOwnerOfSite: sandbox.stub().resolves(true), }), }, diff --git a/test/support/access-control-util.test.js b/test/support/access-control-util.test.js index 54fd36bca..ff5d7489a 100644 --- a/test/support/access-control-util.test.js +++ b/test/support/access-control-util.test.js @@ -20,6 +20,7 @@ import { use, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; import sinon from 'sinon'; +import esmock from 'esmock'; import AccessControlUtil from '../../src/support/access-control-util.js'; use(chaiAsPromised); @@ -2256,4 +2257,175 @@ describe('Access Control Util', () => { expect(util.isLLMOAdministrator()).to.be.true; }); }); + + describe('hasLlmoCapabilityForSite', () => { + const site = { getId: () => 'site-1', getOrganizationId: () => 'org-uuid' }; + + function makeCtx({ + profile = {}, facs, postgrest = true, suffix = '/sites/site-1/llmo/config', method = 'POST', + } = {}) { + return { + // Real FACS-governed route so the derived capability comes from the route + // map (POST /sites/:siteId/llmo/config → llmo/can_configure), not a hardcode. + pathInfo: { method, suffix, headers: { 'x-product': 'llmo' } }, + attributes: { + authInfo: new AuthInfo().withType('jwt').withProfile(profile), + ...(facs ? { facs } : {}), + }, + dataAccess: { + Entitlement: {}, + TrialUser: {}, + OrganizationIdentityProvider: {}, + Organization: { findById: sinon.stub().resolves({ getImsOrgId: () => 'ABC@AdobeOrg' }) }, + ...(postgrest ? { services: { postgrestClient: { from: () => ({}) } } } : {}), + }, + }; + } + + let listBrandIdsForSite; + let listResourceIdsWithCapability; + let ACU; + beforeEach(async () => { + listBrandIdsForSite = sinon.stub(); + listResourceIdsWithCapability = sinon.stub(); + ({ default: ACU } = await esmock('../../src/support/access-control-util.js', { + '../../src/support/brands-storage.js': { listBrandIdsForSite }, + '../../src/support/state-access-mapping-utils.js': { listResourceIdsWithCapability }, + })); + }); + + it('not enrolled → falls back to legacy claim (admin → true)', async () => { + const util = ACU.fromContext(makeCtx({ profile: { is_llmo_administrator: true } })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.true; + expect(listBrandIdsForSite).to.not.have.been.called; + }); + + it('not enrolled → falls back to legacy claim (non-admin → false)', async () => { + const util = ACU.fromContext(makeCtx({ profile: { is_llmo_administrator: false } })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.false; + }); + + it('enrolled and NOT deferred → wrapper already confirmed the capability → allow', async () => { + // facs_enabled but no facs defer marker → JWT short-circuit / state admit upstream. + const util = ACU.fromContext(makeCtx({ profile: { facs_enabled: true } })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.true; + expect(listBrandIdsForSite).to.not.have.been.called; + }); + + it('enrolled and deferred → allows when a linked brand holds the capability', async () => { + listBrandIdsForSite.resolves(new Set(['brand-A', 'brand-B'])); + listResourceIdsWithCapability.resolves(new Set(['brand-B'])); + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.true; + expect(listResourceIdsWithCapability).to.have.been.calledWithMatch(sinon.match.any, { + product: 'LLMO', resourceType: 'brand', capability: 'llmo/can_configure', + }); + }); + + it('enrolled and deferred → denies when no linked brand holds the capability', async () => { + listBrandIdsForSite.resolves(new Set(['brand-A'])); + listResourceIdsWithCapability.resolves(new Set(['brand-Z'])); + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.false; + }); + + it('enrolled and deferred → denies (fail closed) when postgrest is unavailable', async () => { + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + postgrest: false, + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.false; + expect(listBrandIdsForSite).to.not.have.been.called; + }); + + it('enrolled and deferred → denies when the site has no linked brands', async () => { + listBrandIdsForSite.resolves(new Set()); + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.false; + expect(listResourceIdsWithCapability).to.not.have.been.called; + }); + + it('derives the capability from the route (edge-optimize → can_deploy)', async () => { + listBrandIdsForSite.resolves(new Set(['brand-A'])); + listResourceIdsWithCapability.resolves(new Set(['brand-A'])); + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + method: 'POST', + suffix: '/sites/site-1/llmo/edge-optimize-config', + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.true; + expect(listResourceIdsWithCapability).to.have.been.calledWithMatch(sinon.match.any, { + capability: 'llmo/can_deploy', + }); + }); + + it('enrolled and deferred → denies (fail closed) when the route has no capability', async () => { + const util = ACU.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + method: 'GET', + suffix: '/not/a/facs/route', + })); + expect(await util.hasLlmoCapabilityForSite(site)).to.be.false; + expect(listBrandIdsForSite).to.not.have.been.called; + }); + }); + + describe('llmoForbiddenMessage', () => { + function makeCtx({ + profile = {}, facs, suffix = '/sites/site-1/llmo/config', method = 'POST', + } = {}) { + return { + pathInfo: { method, suffix, headers: { 'x-product': 'llmo' } }, + attributes: { + authInfo: new AuthInfo().withType('jwt').withProfile(profile), + ...(facs ? { facs } : {}), + }, + dataAccess: { + Entitlement: {}, TrialUser: {}, OrganizationIdentityProvider: {}, + }, + }; + } + + const LEGACY = 'Only LLMO administrators can update the LLMO config'; + + it('not FACS-enrolled → returns the legacy wording unchanged', () => { + const util = AccessControlUtil.fromContext(makeCtx({ profile: {} })); + expect(util.llmoForbiddenMessage(LEGACY)).to.equal(LEGACY); + }); + + it('FACS-enrolled → returns a capability-oriented message naming the route capability', () => { + // POST /sites/:siteId/llmo/config → llmo/can_configure + const util = AccessControlUtil.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + })); + const msg = util.llmoForbiddenMessage(LEGACY); + expect(msg).to.not.equal(LEGACY); + expect(msg).to.contain('llmo/can_configure'); + }); + + it('FACS-enrolled but route capability unresolvable → generic LLMO permission message', () => { + const util = AccessControlUtil.fromContext(makeCtx({ + profile: { facs_enabled: true }, + facs: { enabled: true, product: 'LLMO', subjectId: 'u@AdobeID' }, + method: 'GET', + suffix: '/not/a/facs/route', + })); + const msg = util.llmoForbiddenMessage(LEGACY); + expect(msg).to.not.equal(LEGACY); + expect(msg).to.contain('LLMO permission'); + }); + }); }); diff --git a/test/support/brands-storage.test.js b/test/support/brands-storage.test.js index 5c709d302..d34ec4fd4 100644 --- a/test/support/brands-storage.test.js +++ b/test/support/brands-storage.test.js @@ -23,6 +23,7 @@ import { getBrandUrlSources, getBrandCompetitors, getBrandBySite, + listBrandIdsForSite, isSemrushMarketMirrorSite, upsertBrand, updateBrand, @@ -664,6 +665,43 @@ describe('brands-storage', () => { }); }); + describe('listBrandIdsForSite', () => { + const SITE_ID = '44444444-4444-4444-8444-444444444444'; + + it('returns empty set when postgrestClient / org / site are missing', async () => { + expect(await listBrandIdsForSite(ORG_ID, SITE_ID, null)).to.deep.equal(new Set()); + expect(await listBrandIdsForSite(ORG_ID, SITE_ID, {})).to.deep.equal(new Set()); + expect(await listBrandIdsForSite('', SITE_ID, { from: () => {} })).to.deep.equal(new Set()); + expect(await listBrandIdsForSite(ORG_ID, '', { from: () => {} })).to.deep.equal(new Set()); + }); + + it('unions the primary brand (brands.site_id) and linked brands (brand_sites)', async () => { + const from = sinon.stub(); + from.withArgs('brands').returns(createChainableQuery({ data: [{ id: 'brand-A' }], error: null })); + from.withArgs('brand_sites').returns(createChainableQuery({ + data: [{ brand_id: 'brand-A' }, { brand_id: 'brand-B' }], error: null, + })); + const result = await listBrandIdsForSite(ORG_ID, SITE_ID, { from }); + expect(result).to.deep.equal(new Set(['brand-A', 'brand-B'])); + }); + + it('throws when the brands query errors', async () => { + const from = sinon.stub(); + from.withArgs('brands').returns(createChainableQuery({ data: null, error: { message: 'boom' } })); + from.withArgs('brand_sites').returns(createChainableQuery({ data: [], error: null })); + await expect(listBrandIdsForSite(ORG_ID, SITE_ID, { from })) + .to.be.rejectedWith(/Failed to resolve brands for site: boom/); + }); + + it('throws when the brand_sites query errors', async () => { + const from = sinon.stub(); + from.withArgs('brands').returns(createChainableQuery({ data: [], error: null })); + from.withArgs('brand_sites').returns(createChainableQuery({ data: null, error: { message: 'nope' } })); + await expect(listBrandIdsForSite(ORG_ID, SITE_ID, { from })) + .to.be.rejectedWith(/Failed to resolve brand-site links for site: nope/); + }); + }); + describe('getBrandBySite', () => { const SITE_ID = '33333333-3333-4333-8333-333333333333';