Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/spacecat-shared-http-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@
},
"dependencies": {
"@adobe/fetch": "4.2.3",
"@adobe/spacecat-shared-launchdarkly-client": "1.0.4",
"@adobe/spacecat-shared-utils": "1.81.1",
"jose": "6.2.2"
},
"devDependencies": {
"@adobe/helix-shared-wrap": "2.0.2",
"chai": "6.2.2",
"chai-as-promised": "8.0.2",
"esmock": "2.7.3",
"sinon": "21.0.3"
}
}
2 changes: 2 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/auth-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export default class AuthInfo {

isS2SConsumer() { return this.profile?.is_s2s_consumer; }

isReadOnlyAdmin() { return this.profile?.is_read_only_admin; }

hasOrganization(orgId) {
const [id] = orgId.split('@');
return this.profile?.tenants?.some(
Expand Down
13 changes: 13 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

export const FF_READ_ONLY_ORG = 'FT_READ_ONLY_ORG';
39 changes: 39 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/handlers/ims.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
decodeJwt,
jwtVerify,
} from 'jose';
import { LaunchDarklyClient } from '@adobe/spacecat-shared-launchdarkly-client';
import { getBearerToken } from './utils/bearer.js';
import AbstractHandler from './abstract.js';
import AuthInfo from '../auth-info.js';
import { FF_READ_ONLY_ORG } from '../constants.js';

const IGNORED_PROFILE_PROPS = [
'id',
Expand Down Expand Up @@ -99,6 +101,37 @@ function isUserASOAdmin(organizations) {
return org.groups.some((group) => adminGroupsForOrg.includes(group.ident));
});
}

/**
* Checks whether the read-only org gate flag is enabled for the user's first
* IMS organization. When true, ALL IMS-authenticated users in that org are
* blocked (not just RO admins) - this intentionally forces the entire org to
* authenticate via the JWT/auth-service path instead.
*
* NOTE: Only the first org in the array is evaluated. Multi-org users whose
* read-only org is not first may bypass this gate; this is an accepted
* limitation given IMS org ordering is not guaranteed stable.
*
* Fail-open: returns false (allowing authentication) when the LD client is
* unavailable or evaluation errors.
*/
async function isOrgBlockedFromImsAuth(context, organizations) {
if (!isNonEmptyArray(organizations)) return false;

try {
const ldClient = LaunchDarklyClient.createFrom(context);
if (!ldClient) return false;

// Only evaluate the first org - see NOTE above.
const ident = organizations[0]?.orgRef?.ident;
if (!ident) return false;

const imsOrgId = `${ident}@AdobeOrg`;
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ORG, imsOrgId);
} catch {
return false;
}
}
/**
* @deprecated Use JwtHandler instead in the context of IMS login with subsequent JWT exchange.
*/
Expand Down Expand Up @@ -172,6 +205,12 @@ export default class AdobeImsHandler extends AbstractHandler {
const payload = await this.#validateToken(token, config);
const imsProfile = await context.imsClient.getImsUserProfile(token);
const organizations = await context.imsClient.getImsUserOrganizations(token);
// Blocks ALL users in the org (not just RO admins) when the gate flag is
// enabled — this is intentional: the entire org must migrate to the JWT path.
if (await isOrgBlockedFromImsAuth(context, organizations)) {
this.log('User belongs to a read-only org, blocking IMS authentication', 'warn');
throw new Error('Unauthorized');
}
const isAdmin = isUserASOAdmin(organizations);
const scopes = [];
if (imsProfile.email?.toLowerCase().endsWith('@adobe.com') && isAdmin) {
Expand Down
12 changes: 11 additions & 1 deletion packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,17 @@ export default class JwtHandler extends AbstractHandler {
const payload = await validateToken(token, this.authPublicKey);
payload.tenants = payload.tenants || [];

const scopes = payload.is_admin ? [{ name: 'admin' }] : [];
if (payload.is_admin && payload.is_read_only_admin) {
this.log('Token has both is_admin and is_read_only_admin - rejecting', 'warn');
return null;
}

const scopes = [];
if (payload.is_admin) {
scopes.push({ name: 'admin' });
} else if (payload.is_read_only_admin) {
scopes.push({ name: 'read_only_admin' });
}

scopes.push(...payload.tenants.map(
(tenant) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { Response } from '@adobe/fetch';
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';

function forbidden(message) {
return new Response(JSON.stringify({ message }), {
status: 403,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'x-error': message },
});
}

/**
* Evaluates the read-only admin feature flag for the authenticated user's IMS org.
* Uses {@link AuthInfo#getTenantIds} to resolve the org and
* {@link LaunchDarklyClient#isFlagEnabledForIMSOrg} for evaluation.
* Fail-closed: returns false when the client/org is unavailable or evaluation errors.
*
* @param {Object} context - Universal context (lambda context)
* @param {Object} authInfo - The AuthInfo instance for the current user
* @returns {Promise<boolean>}
*/
async function evaluateFeatureFlag(context, authInfo) {
try {
const ldClient = LaunchDarklyClient.createFrom(context);
if (!ldClient) return false;

const tenantIds = authInfo.getTenantIds?.() || [];
const ident = tenantIds[0];
if (!ident) return false;

const imsOrgId = `${ident}@AdobeOrg`;
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ORG, imsOrgId);
} catch {
return false;
}
}

/**
* Read-only admin authorization wrapper for the helix-shared-wrap `.with()` chain.
*
* After successful authentication (authInfo already set on context by an earlier
* wrapper), this wrapper checks whether the authenticated user is a read-only admin.
* If so it:
*
* 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.
*
* Non-RO-admin requests pass through untouched.
*
* @param {Function} fn - The handler to wrap.
* @param {{ routeCapabilities: Object<string, string> }} opts - Required map of route
* patterns (e.g. 'GET /sites/:siteId') to action strings ('read' | 'write').
* @returns {Function} A wrapped handler.
*/
export function readOnlyAdminWrapper(fn, { routeCapabilities } = {}) {
if (!routeCapabilities) {
throw new Error('readOnlyAdminWrapper: routeCapabilities is required');
}
guardNonEmptyRouteCapabilities('readOnlyAdminWrapper', routeCapabilities);

return async (request, context) => {
const { log } = context;
const authInfo = context.attributes?.authInfo;

if (authInfo?.isReadOnlyAdmin?.()) {
const ffEnabled = await evaluateFeatureFlag(context, authInfo);
if (!ffEnabled) {
log.warn({
tag: 'ro-admin',
email: authInfo.getProfile?.()?.email,
org: authInfo.getTenantIds?.()[0],
}, 'Feature flag disabled, denying RO admin access');
return forbidden('Forbidden');
}

if (isObject(routeCapabilities)) {
const capability = resolveRouteCapability(context, routeCapabilities);
// capability format is 'resource:action' (e.g. 'site:read', 'site:write').
// split(':').pop() extracts the action; a missing or malformed value yields
// undefined, which is !== 'read' and correctly blocks the request.
const action = capability?.split(':').pop();

if (action !== 'read') {
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');
}
}

log.info({
tag: 'ro-admin-audit',
email: authInfo.getProfile?.()?.email,
method: context.pathInfo?.method,
suffix: context.pathInfo?.suffix,
org: authInfo.getTenantIds?.()[0],
}, 'RO admin accessed route');
}

return fn(request, context);
};
}
69 changes: 69 additions & 0 deletions packages/spacecat-shared-http-utils/src/auth/route-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { isObject } from '@adobe/spacecat-shared-utils';

/**
* Matches pre-split request segments against a route pattern with :param segments.
* e.g. ['sites', 'abc-123', 'audits'] matches 'GET /sites/:siteId/audits'
*/
function matchRoute(method, requestSegments, routeKey) {
const spaceIdx = routeKey.indexOf(' ');
if (spaceIdx === -1) return false;

const routeMethod = routeKey.slice(0, spaceIdx);
if (routeMethod !== method) return false;

const routeSegments = routeKey.slice(spaceIdx + 1).split('/').filter(Boolean);
if (routeSegments.length !== requestSegments.length) return false;

return routeSegments.every(
(seg, i) => seg.charCodeAt(0) === 58 /* ':' */ || seg === requestSegments[i],
);
}

/**
* Looks up the value mapped to the current request from a route map
* using the method and path from context.pathInfo. Supports both exact
* matches and parameterized route patterns (e.g. 'GET /sites/:siteId').
*
* @param {Object} context - Universal context with pathInfo
* @param {Object<string, string>} routeMap - Route pattern to value map
* @returns {string|null} The matched value or null
*/
export function resolveRouteCapability(context, routeMap) {
const method = context.pathInfo?.method?.toUpperCase();
const path = context.pathInfo?.suffix;
if (!method || !path) return null;

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

const requestSegments = path.split('/').filter(Boolean);
const matchedKey = Object.keys(routeMap)
.find((key) => matchRoute(method, requestSegments, key));
return matchedKey ? routeMap[matchedKey] : null;
}

/**
* Throws at wrapper creation time if routeCapabilities is an empty object.
* An empty map would silently deny/block all requests, so this is a
* fail-fast guard against misconfiguration.
*
* @param {string} wrapperName - Name of the wrapper (for the error message)
* @param {Object} routeCapabilities - The route capabilities map
*/
export function guardNonEmptyRouteCapabilities(wrapperName, routeCapabilities) {
if (isObject(routeCapabilities) && Object.keys(routeCapabilities).length === 0) {
throw new Error(`${wrapperName}: routeCapabilities must not be an empty object`);
}
}
44 changes: 3 additions & 41 deletions packages/spacecat-shared-http-utils/src/auth/s2s-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,7 @@ import { hasText, isNonEmptyArray, isObject } from '@adobe/spacecat-shared-utils

import { getBearerToken } from './handlers/utils/bearer.js';
import { loadPublicKey, validateToken } from './handlers/utils/token.js';

/**
* Matches pre-split request segments against a route pattern with :param segments.
* e.g. ['sites', 'abc-123', 'audits'] matches 'GET /sites/:siteId/audits'
*/
function matchRoute(method, requestSegments, routeKey) {
const spaceIdx = routeKey.indexOf(' ');
if (spaceIdx === -1) return false;

const routeMethod = routeKey.slice(0, spaceIdx);
if (routeMethod !== method) return false;

const routeSegments = routeKey.slice(spaceIdx + 1).split('/').filter(Boolean);
if (routeSegments.length !== requestSegments.length) return false;

return routeSegments.every(
(seg, i) => seg.charCodeAt(0) === 58 /* ':' */ || seg === requestSegments[i],
);
}

/**
* Looks up the required capability for the current request from the
* routeCapabilities map using the method and path from context.pathInfo.
*/
function resolveCapability(context, routeCapabilities) {
const method = context.pathInfo?.method?.toUpperCase();
const path = context.pathInfo?.suffix;
if (!method || !path) return null;

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

const requestSegments = path.split('/').filter(Boolean);
const matchedKey = Object.keys(routeCapabilities)
.find((key) => matchRoute(method, requestSegments, key));
return matchedKey ? routeCapabilities[matchedKey] : null;
}
import { guardNonEmptyRouteCapabilities, resolveRouteCapability } from './route-utils.js';

/**
* S2S consumer auth wrapper for the helix-shared-wrap `.with()` chain.
Expand All @@ -71,9 +35,7 @@ function resolveCapability(context, routeCapabilities) {
* @returns {Function} A wrapped handler.
*/
export function s2sAuthWrapper(fn, { routeCapabilities } = {}) {
if (isObject(routeCapabilities) && Object.keys(routeCapabilities).length === 0) {
throw new Error('s2sAuthWrapper: routeCapabilities must not be an empty object — this would silently deny all S2S requests');
}
guardNonEmptyRouteCapabilities('s2sAuthWrapper', routeCapabilities);

let publicKey;

Expand Down Expand Up @@ -145,7 +107,7 @@ export function s2sAuthWrapper(fn, { routeCapabilities } = {}) {
}

if (isObject(routeCapabilities)) {
const requiredCapability = resolveCapability(context, routeCapabilities);
const requiredCapability = resolveRouteCapability(context, routeCapabilities);
if (!hasText(requiredCapability)) {
log.warn(`[s2s] Route ${context.pathInfo?.method} ${context.pathInfo?.suffix} is not allowed for S2S consumers`);
return new Response('Forbidden', { status: 403 });
Expand Down
Loading
Loading