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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { isObject } from '@adobe/spacecat-shared-utils';
import { LaunchDarklyClient } from '@adobe/spacecat-shared-launchdarkly-client';

import { FF_READ_ONLY_ORG } from './constants.js';
import { guardNonEmptyRouteCapabilities, resolveRouteCapability } from './route-utils.js';
import {
extractRouteParams,
guardNonEmptyRouteCapabilities,
resolveRouteCapability,
} from './route-utils.js';

function forbidden(message) {
return new Response(JSON.stringify({ message }), {
Expand All @@ -24,6 +28,74 @@ function forbidden(message) {
});
}

/**
* Checks whether the authenticated read-only admin user owns the resource identified
* by the path parameters or request body. Ownership is determined by whether the user's
* IMS org matches the organization that owns the referenced site or organization entity.
*
* ID resolution order:
* 1. Named path params (e.g. :siteId, :organizationId) — always preferred.
* 2. context.data (parsed request body) — used only when the matched route has no path
* params at all (e.g. POST /preflight/jobs passes siteId in the body). Requires that
* the body-parser wrapper runs before readOnlyAdminWrapper in the .with() chain.
*
* Fail-closed: returns false if dataAccess is unavailable, the entity is not found,
* no recognizable ID can be resolved, or any lookup throws.
*
* @param {Object} context - Universal context (must have context.dataAccess and context.log)
* @param {Object} authInfo - The AuthInfo instance for the current user
* @param {Object<string, string>} params - Named path params extracted from the route pattern
* @returns {Promise<boolean>}
*/
async function isOwnerOfResource(context, authInfo, params) {
const { dataAccess, log } = context;
if (!dataAccess) {
log.error({ tag: 'ro-admin' }, 'isOwnerOfResource: dataAccess not on context — ensure dataAccessWrapper runs before readOnlyAdminWrapper');
return false;
}

// Body fallback only when the route carries no path params at all (e.g. POST /preflight/jobs).
// When params has keys, the route does have path identifiers; relying on body data in that
// case could allow a caller to spoof ownership by naming params differently than :siteId.
const hasPathParams = Object.keys(params).length > 0;
const siteId = hasPathParams ? params.siteId : (params.siteId ?? context.data?.siteId);
const organizationId = hasPathParams
? params.organizationId
: (params.organizationId ?? context.data?.organizationId);

try {
if (siteId) {
const site = await dataAccess.Site?.findById(siteId);
if (!site) {
return false;
}
const org = await site.getOrganization();
if (!org) {
return false;
}
return authInfo.hasOrganization(org.getImsOrgId());
}

if (organizationId) {
const org = await dataAccess.Organization?.findById(organizationId);
if (!org) {
return false;
}
return authInfo.hasOrganization(org.getImsOrgId());
}
} catch (err) {
log.error({ tag: 'ro-admin', err }, 'Error checking resource ownership for RO admin');
return false;
}

log.warn({
tag: 'ro-admin',
method: context.pathInfo?.method,
suffix: context.pathInfo?.suffix,
}, 'isOwnerOfResource: no siteId or organizationId found in path params or context.data');
return false;
}

/**
* Evaluates the read-only admin feature flag for the authenticated user's IMS org.
* Uses {@link AuthInfo#getTenantIds} to resolve the org and
Expand All @@ -35,6 +107,7 @@ function forbidden(message) {
* @returns {Promise<boolean>}
*/
async function evaluateFeatureFlag(context, authInfo) {
const { log } = context;
try {
const ldClient = LaunchDarklyClient.createFrom(context);
if (!ldClient) {
Expand All @@ -49,7 +122,8 @@ async function evaluateFeatureFlag(context, authInfo) {

const imsOrgId = `${ident}@AdobeOrg`;
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ORG, imsOrgId);
} catch {
} catch (err) {
log.error({ tag: 'ro-admin', err }, 'Feature flag evaluation failed for RO admin; defaulting to deny');
return false;
}
}
Expand All @@ -63,8 +137,9 @@ async function evaluateFeatureFlag(context, authInfo) {
*
* 1. Evaluates the `FT_READ_ONLY_ORG` LaunchDarkly feature flag (fail-closed).
* 2. Resolves the route's action from the routeCapabilities map and blocks
* write operations (or unmapped routes) for RO admins.
* 3. Emits a structured audit log entry for allowed RO admin requests.
* write operations (or unmapped routes) for RO admins, unless they own the resource.
* 3. Emits a structured audit log entry for all allowed RO admin requests, including
* the resolved resource ID and whether it came from the path or request body.
*
* Non-RO-admin requests pass through untouched.
*
Expand Down Expand Up @@ -102,14 +177,38 @@ export function readOnlyAdminWrapper(fn, { routeCapabilities } = {}) {
const action = capability?.split(':').pop();

if (action !== 'read' && action !== 'readAll') {
log.warn({
tag: 'ro-admin',
// Allow the write if the RO admin owns the target resource.
let params;
try {
params = extractRouteParams(context, routeCapabilities);
} catch (err) {
log.error({ tag: 'ro-admin', err }, 'extractRouteParams failed; denying write access');
return forbidden('Forbidden');
}

const isOwner = await isOwnerOfResource(context, authInfo, params);
if (!isOwner) {
log.warn({
tag: 'ro-admin',
email: authInfo.getProfile?.()?.email,
method: context.pathInfo?.method,
suffix: context.pathInfo?.suffix,
org: authInfo.getTenantIds?.()[0],
}, 'Read-only admin blocked from route');
return forbidden('Forbidden');
}

const hasPathParams = Object.keys(params).length > 0;
log.info({
tag: 'ro-admin-write',
email: authInfo.getProfile?.()?.email,
method: context.pathInfo?.method,
suffix: context.pathInfo?.suffix,
org: authInfo.getTenantIds?.()[0],
}, 'Read-only admin blocked from route');
return forbidden('Forbidden');
resolvedSiteId: params.siteId ?? context.data?.siteId ?? null,
resolvedOrgId: params.organizationId ?? context.data?.organizationId ?? null,
idSource: hasPathParams ? 'path' : 'body',
}, 'RO admin write allowed on owned resource');
}
}

Expand Down
38 changes: 38 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/route-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,44 @@ export function resolveRouteCapability(context, routeMap) {
return matchedKey ? routeMap[matchedKey] : null;
}

/**
* Extracts named path parameters from the route pattern that matches the current request.
* e.g. route 'PATCH /sites/:siteId', suffix '/sites/abc-123' → { siteId: 'abc-123' }
* Returns an empty object when there is no match or the match has no parameters.
*
* @param {Object} context - Universal context with pathInfo
* @param {Object<string, string>} routeMap - Route pattern to value map
* @returns {Object<string, string>}
*/
export function extractRouteParams(context, routeMap) {
const method = context.pathInfo?.method?.toUpperCase();
const path = context.pathInfo?.suffix;
if (!method || !path) {
return {};
}

const exactKey = `${method} ${path}`;
if (routeMap[exactKey]) {
return {};
}

const requestSegments = path.split('/').filter(Boolean);
const matchedKey = Object.keys(routeMap)
.find((key) => matchRoute(method, requestSegments, key));
if (!matchedKey) {
return {};
}

const routeSegments = matchedKey.slice(matchedKey.indexOf(' ') + 1).split('/').filter(Boolean);
const params = {};
routeSegments.forEach((seg, i) => {
if (seg.charCodeAt(0) === 58 /* ':' */) {
params[seg.slice(1)] = requestSegments[i];
}
});
return params;
}

/**
* Throws at wrapper creation time if routeCapabilities is an empty object.
* An empty map would silently deny/block all requests, so this is a
Expand Down
Loading
Loading