Skip to content

Commit cda32d8

Browse files
committed
fix: review commits
1 parent 5d11789 commit cda32d8

File tree

7 files changed

+67
-60
lines changed

7 files changed

+67
-60
lines changed

packages/spacecat-shared-http-utils/src/auth/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
export const FF_READ_ONLY_ADMIN = 'FT_LLMO-3008';
13+
export const FF_READ_ONLY_ORG = 'FT_READ_ONLY_ORG';

packages/spacecat-shared-http-utils/src/auth/handlers/ims.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { LaunchDarklyClient } from '@adobe/spacecat-shared-launchdarkly-client';
2121
import { getBearerToken } from './utils/bearer.js';
2222
import AbstractHandler from './abstract.js';
2323
import AuthInfo from '../auth-info.js';
24-
import { FF_READ_ONLY_ADMIN } from '../constants.js';
24+
import { FF_READ_ONLY_ORG } from '../constants.js';
2525

2626
const IGNORED_PROFILE_PROPS = [
2727
'id',
@@ -103,22 +103,31 @@ function isUserASOAdmin(organizations) {
103103
}
104104

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

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

125+
// Only evaluate the first org — see NOTE above.
117126
const ident = organizations[0]?.orgRef?.ident;
118127
if (!ident) return false;
119128

120129
const imsOrgId = `${ident}@AdobeOrg`;
121-
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ADMIN, imsOrgId);
130+
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ORG, imsOrgId);
122131
} catch {
123132
return false;
124133
}
@@ -196,7 +205,9 @@ export default class AdobeImsHandler extends AbstractHandler {
196205
const payload = await this.#validateToken(token, config);
197206
const imsProfile = await context.imsClient.getImsUserProfile(token);
198207
const organizations = await context.imsClient.getImsUserOrganizations(token);
199-
if (await isReadOnlyAdminEnabled(context, organizations)) {
208+
// Blocks ALL users in the org (not just RO admins) when the gate flag is
209+
// enabled — this is intentional: the entire org must migrate to the JWT path.
210+
if (await isOrgBlockedFromImsAuth(context, organizations)) {
200211
this.log('User belongs to a read-only org, blocking IMS authentication', 'warn');
201212
throw new Error('Unauthorized');
202213
}

packages/spacecat-shared-http-utils/src/auth/handlers/jwt.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default class JwtHandler extends AbstractHandler {
4242
payload.tenants = payload.tenants || [];
4343

4444
if (payload.is_admin && payload.is_read_only_admin) {
45-
this.log('Token has both is_admin and is_read_only_admin rejecting', 'warn');
45+
this.log('Token has both is_admin and is_read_only_admin - rejecting', 'warn');
4646
return null;
4747
}
4848

packages/spacecat-shared-http-utils/src/auth/read-only-admin-wrapper.js

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Response } from '@adobe/fetch';
1414
import { isObject } from '@adobe/spacecat-shared-utils';
1515
import { LaunchDarklyClient } from '@adobe/spacecat-shared-launchdarkly-client';
1616

17-
import { FF_READ_ONLY_ADMIN } from './constants.js';
17+
import { FF_READ_ONLY_ORG } from './constants.js';
1818
import { guardNonEmptyRouteCapabilities, resolveRouteCapability } from './route-utils.js';
1919

2020
function forbidden(message) {
@@ -44,7 +44,7 @@ async function evaluateFeatureFlag(context, authInfo) {
4444
if (!ident) return false;
4545

4646
const imsOrgId = `${ident}@AdobeOrg`;
47-
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ADMIN, imsOrgId);
47+
return await ldClient.isFlagEnabledForIMSOrg(FF_READ_ONLY_ORG, imsOrgId);
4848
} catch {
4949
return false;
5050
}
@@ -70,6 +70,9 @@ async function evaluateFeatureFlag(context, authInfo) {
7070
* @returns {Function} A wrapped handler.
7171
*/
7272
export function readOnlyAdminWrapper(fn, { routeCapabilities } = {}) {
73+
if (!routeCapabilities) {
74+
throw new Error('readOnlyAdminWrapper: routeCapabilities is required');
75+
}
7376
guardNonEmptyRouteCapabilities('readOnlyAdminWrapper', routeCapabilities);
7477

7578
return async (request, context) => {
@@ -79,22 +82,37 @@ export function readOnlyAdminWrapper(fn, { routeCapabilities } = {}) {
7982
if (authInfo?.isReadOnlyAdmin?.()) {
8083
const ffEnabled = await evaluateFeatureFlag(context, authInfo);
8184
if (!ffEnabled) {
82-
log.warn('[ro-admin] Feature flag disabled, denying RO admin access');
83-
return forbidden('Read-only admin access is not enabled');
85+
log.warn({
86+
tag: 'ro-admin',
87+
org: authInfo.getTenantIds?.()[0],
88+
}, 'Feature flag disabled, denying RO admin access');
89+
return forbidden('Forbidden');
8490
}
8591

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

9099
if (action !== 'read') {
91-
const route = `${context.pathInfo?.method} ${context.pathInfo?.suffix}`;
92-
log.warn(`[ro-admin] Read-only admin blocked from route: ${route}`);
93-
return forbidden('Read-only admin users cannot perform write operations');
100+
log.warn({
101+
tag: 'ro-admin',
102+
method: context.pathInfo?.method,
103+
suffix: context.pathInfo?.suffix,
104+
org: authInfo.getTenantIds?.()[0],
105+
}, 'Read-only admin blocked from route');
106+
return forbidden('Forbidden');
94107
}
95108
}
96109

97-
log.info(`[ro-admin-audit] RO admin accessed: ${context.pathInfo?.method} ${context.pathInfo?.suffix}`);
110+
log.info({
111+
tag: 'ro-admin-audit',
112+
method: context.pathInfo?.method,
113+
suffix: context.pathInfo?.suffix,
114+
org: authInfo.getTenantIds?.()[0],
115+
}, 'RO admin accessed route');
98116
}
99117

100118
return fn(request, context);

packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ describe('AdobeImsHandler', () => {
449449
expect(logStub.debug.calledWithMatch('Failed to validate token: Unauthorized')).to.be.true;
450450
expect(ldClient.isFlagEnabledForIMSOrg.calledOnce).to.be.true;
451451
const [flagKey, imsOrgId] = ldClient.isFlagEnabledForIMSOrg.firstCall.args;
452-
expect(flagKey).to.equal('FT_LLMO-3008');
452+
expect(flagKey).to.equal('FT_READ_ONLY_ORG');
453453
expect(imsOrgId).to.equal('org-ro-1@AdobeOrg');
454454
});
455455

packages/spacecat-shared-http-utils/test/auth/handlers/jwt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ describe('SpacecatJWTHandler', () => {
312312
const result = await handler.checkAuth({}, context);
313313

314314
expect(result).to.be.null;
315-
expect(logStub.warn.calledWith('[jwt] Token has both is_admin and is_read_only_admin rejecting')).to.be.true;
315+
expect(logStub.warn.calledWith('[jwt] Token has both is_admin and is_read_only_admin - rejecting')).to.be.true;
316316
});
317317

318318
it('successfully validates a token with is_read_only_admin and adds read_only_admin scope', async () => {

packages/spacecat-shared-http-utils/test/auth/read-only-admin-wrapper.test.js

Lines changed: 20 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ describe('readOnlyAdminWrapper', () => {
116116

117117
expect(result.status).to.equal(403);
118118
const body = await result.json();
119-
expect(body.message).to.equal('Read-only admin access is not enabled');
120-
expect(logStub.warn.calledWithMatch('Feature flag disabled')).to.be.true;
119+
expect(body.message).to.equal('Forbidden');
120+
expect(logStub.warn.calledWithMatch({ tag: 'ro-admin' }, 'Feature flag disabled, denying RO admin access')).to.be.true;
121121
expect(handler.called).to.be.false;
122122
});
123123

@@ -134,7 +134,7 @@ describe('readOnlyAdminWrapper', () => {
134134

135135
expect(result.status).to.equal(403);
136136
const body = await result.json();
137-
expect(body.message).to.equal('Read-only admin access is not enabled');
137+
expect(body.message).to.equal('Forbidden');
138138
expect(handler.called).to.be.false;
139139
});
140140

@@ -151,7 +151,7 @@ describe('readOnlyAdminWrapper', () => {
151151

152152
expect(result.status).to.equal(403);
153153
const body = await result.json();
154-
expect(body.message).to.equal('Read-only admin access is not enabled');
154+
expect(body.message).to.equal('Forbidden');
155155
expect(handler.called).to.be.false;
156156
});
157157

@@ -162,7 +162,7 @@ describe('readOnlyAdminWrapper', () => {
162162

163163
expect(result.status).to.equal(403);
164164
const body = await result.json();
165-
expect(body.message).to.equal('Read-only admin access is not enabled');
165+
expect(body.message).to.equal('Forbidden');
166166
expect(handler.called).to.be.false;
167167
});
168168

@@ -191,7 +191,7 @@ describe('readOnlyAdminWrapper', () => {
191191

192192
expect(ldClient.isFlagEnabledForIMSOrg.calledOnce).to.be.true;
193193
const [flagKey, imsOrgId] = ldClient.isFlagEnabledForIMSOrg.firstCall.args;
194-
expect(flagKey).to.equal('FT_LLMO-3008');
194+
expect(flagKey).to.equal('FT_READ_ONLY_ORG');
195195
expect(imsOrgId).to.equal('org-abc@AdobeOrg');
196196
});
197197
});
@@ -239,8 +239,8 @@ describe('readOnlyAdminWrapper', () => {
239239

240240
expect(result.status).to.equal(403);
241241
const body = await result.json();
242-
expect(body.message).to.equal('Read-only admin users cannot perform write operations');
243-
expect(logStub.warn.calledWithMatch('blocked from route')).to.be.true;
242+
expect(body.message).to.equal('Forbidden');
243+
expect(logStub.warn.calledWithMatch({ tag: 'ro-admin' }, 'Read-only admin blocked from route')).to.be.true;
244244
expect(handler.called).to.be.false;
245245
});
246246

@@ -251,7 +251,7 @@ describe('readOnlyAdminWrapper', () => {
251251

252252
expect(result.status).to.equal(403);
253253
const body = await result.json();
254-
expect(body.message).to.equal('Read-only admin users cannot perform write operations');
254+
expect(body.message).to.equal('Forbidden');
255255
expect(handler.called).to.be.false;
256256
});
257257

@@ -271,8 +271,8 @@ describe('readOnlyAdminWrapper', () => {
271271

272272
expect(result.status).to.equal(403);
273273
const body = await result.json();
274-
expect(body.message).to.equal('Read-only admin users cannot perform write operations');
275-
expect(logStub.warn.calledWithMatch('blocked from route')).to.be.true;
274+
expect(body.message).to.equal('Forbidden');
275+
expect(logStub.warn.calledWithMatch({ tag: 'ro-admin' }, 'Read-only admin blocked from route')).to.be.true;
276276
expect(handler.called).to.be.false;
277277
});
278278

@@ -297,36 +297,14 @@ describe('readOnlyAdminWrapper', () => {
297297
});
298298

299299
describe('no routeCapabilities provided', () => {
300-
let mockedWrapper;
301-
302-
beforeEach(async () => {
303-
const ldClient = {
304-
isFlagEnabledForIMSOrg: sinon.stub().resolves(true),
305-
};
306-
const mockedModule = await esmock('../../src/auth/read-only-admin-wrapper.js', {
307-
'@adobe/spacecat-shared-launchdarkly-client': {
308-
LaunchDarklyClient: {
309-
createFrom: sinon.stub().returns(ldClient),
310-
},
311-
},
312-
});
313-
mockedWrapper = mockedModule.readOnlyAdminWrapper;
314-
});
315-
316-
it('passes through RO admin when routeCapabilities is not provided', async () => {
317-
const wrapped = mockedWrapper(handler);
318-
const result = await wrapped({}, context);
319-
320-
expect(result).to.deep.equal({ status: 200 });
321-
expect(handler.calledOnce).to.be.true;
300+
it('throws at creation time when routeCapabilities is not provided', () => {
301+
expect(() => readOnlyAdminWrapper(handler))
302+
.to.throw('readOnlyAdminWrapper: routeCapabilities is required');
322303
});
323304

324-
it('passes through RO admin when routeCapabilities is null', async () => {
325-
const wrapped = mockedWrapper(handler, { routeCapabilities: null });
326-
const result = await wrapped({}, context);
327-
328-
expect(result).to.deep.equal({ status: 200 });
329-
expect(handler.calledOnce).to.be.true;
305+
it('throws at creation time when routeCapabilities is null', () => {
306+
expect(() => readOnlyAdminWrapper(handler, { routeCapabilities: null }))
307+
.to.throw('readOnlyAdminWrapper: routeCapabilities is required');
330308
});
331309
});
332310

@@ -352,23 +330,23 @@ describe('readOnlyAdminWrapper', () => {
352330
const wrapped = mockedWrapper(handler, { routeCapabilities });
353331
await wrapped({}, context);
354332

355-
expect(logStub.info.calledWithMatch('[ro-admin-audit] RO admin accessed: GET /sites')).to.be.true;
333+
expect(logStub.info.calledWithMatch({ tag: 'ro-admin-audit', method: 'GET', suffix: '/sites' }, 'RO admin accessed route')).to.be.true;
356334
});
357335

358336
it('does not emit audit log for non-RO-admin requests', async () => {
359337
context.attributes.authInfo.isReadOnlyAdmin = () => false;
360338
const wrapped = mockedWrapper(handler, { routeCapabilities });
361339
await wrapped({}, context);
362340

363-
expect(logStub.info.calledWithMatch('[ro-admin-audit]')).to.be.false;
341+
expect(logStub.info.calledWithMatch({ tag: 'ro-admin-audit' })).to.be.false;
364342
});
365343

366344
it('does not emit audit log when RO admin is blocked', async () => {
367345
context.pathInfo = { method: 'POST', suffix: '/sites' };
368346
const wrapped = mockedWrapper(handler, { routeCapabilities });
369347
await wrapped({}, context);
370348

371-
expect(logStub.info.calledWithMatch('[ro-admin-audit]')).to.be.false;
349+
expect(logStub.info.calledWithMatch({ tag: 'ro-admin-audit' })).to.be.false;
372350
});
373351
});
374352
});

0 commit comments

Comments
 (0)