Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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_ADMIN = 'FT_LLMO-3008';
28 changes: 28 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_ADMIN } from '../constants.js';

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

/**
* Checks whether the read-only admin feature flag is enabled for the user's
* first IMS organization. Fail-closed: returns false when the LD client is
* unavailable or evaluation errors.
*/
async function isReadOnlyAdminEnabled(context, organizations) {
if (!isNonEmptyArray(organizations)) return false;

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

const ident = organizations[0]?.orgRef?.ident;
if (!ident) return false;

const imsOrgId = `${ident}@AdobeOrg`;
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ADMIN, imsOrgId);
} catch {
return false;
}
}
/**
* @deprecated Use JwtHandler instead in the context of IMS login with subsequent JWT exchange.
*/
Expand Down Expand Up @@ -172,6 +196,10 @@ 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);
if (await isReadOnlyAdminEnabled(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,102 @@
/*
* 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_ADMIN } 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_ADMIN, 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_LLMO-3008` LaunchDarkly feature flag (fail-closed).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: Stale reference - still says FT_LLMO-3008 and "fail-closed". Should be FT_READ_ONLY_ORG. Also, the @param on line 68 still marks routeCapabilities? as optional - should drop the ? since it is now required.

* 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 - Map of route
* patterns (e.g. 'GET /sites/:siteId') to action strings ('read' | 'write').
* @returns {Function} A wrapped handler.
*/
export function readOnlyAdminWrapper(fn, { routeCapabilities } = {}) {
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('[ro-admin] Feature flag disabled, denying RO admin access');
return forbidden('Read-only admin access is not enabled');
}

if (isObject(routeCapabilities)) {
const capability = resolveRouteCapability(context, routeCapabilities);
const action = capability?.split(':').pop();

if (action !== 'read') {
const route = `${context.pathInfo?.method} ${context.pathInfo?.suffix}`;
log.warn(`[ro-admin] Read-only admin blocked from route: ${route}`);
return forbidden('Read-only admin users cannot perform write operations');
}
}

log.info(`[ro-admin-audit] RO admin accessed: ${context.pathInfo?.method} ${context.pathInfo?.suffix}`);
}

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
13 changes: 13 additions & 0 deletions packages/spacecat-shared-http-utils/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export declare function unauthorized(message?: string, headers?: object): Respon

export declare function forbidden(message?: string, headers?: object): Response;

/**
* Read-only admin authorization wrapper for the helix-shared-wrap `.with()` chain.
* Blocks write operations for read-only admin users, gated by a LaunchDarkly feature flag.
*
* @param fn - The handler to wrap.
* @param opts - Options containing a routeCapabilities map of route patterns to actions.
* @returns A wrapped handler.
*/
export function readOnlyAdminWrapper(
fn: Function,
opts?: { routeCapabilities?: Record<string, string> },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: Both opts? and routeCapabilities? are marked optional, but the runtime now throws if routeCapabilities is missing. Should be:

export function readOnlyAdminWrapper(
  fn: Function,
  opts: { routeCapabilities: Record<string, string> },
): Function;

Remove both ? to match the new required contract.

): Function;

/**
* Utility functions
*/
Expand Down
1 change: 1 addition & 0 deletions packages/spacecat-shared-http-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function internalServerError(message = 'internal server error', headers =
}

export { authWrapper } from './auth/auth-wrapper.js';
export { readOnlyAdminWrapper } from './auth/read-only-admin-wrapper.js';
export { s2sAuthWrapper } from './auth/s2s-wrapper.js';
export { enrichPathInfo } from './enrich-path-info-wrapper.js';
export { hashWithSHA256 } from './auth/generate-hash.js';
Expand Down
Loading
Loading