Skip to content
Merged
6 changes: 4 additions & 2 deletions src/controllers/agentic-rules-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
48 changes: 24 additions & 24 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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())) {
Expand Down Expand Up @@ -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') {
Expand Down
142 changes: 142 additions & 0 deletions src/support/access-control-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down Expand Up @@ -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<boolean>}
*/
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;
Expand Down
47 changes: 47 additions & 0 deletions src/support/brands-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>} 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).
Expand Down
Loading
Loading