Skip to content

Commit d95e2c6

Browse files
ravvermaclaude
andcommitted
feat(serenity): mint dedicated Semrush IMS token for member-add instead of caller token
The /serenity/members grant is now authenticated by a token minted for a dedicated Semrush IMS technical account (SEMRUSH_IMS_TECH_ID/SECRET, client_credentials) rather than the calling user's IMS token. This lets the "user hits 401/403 → provision them" flow add a user who is not yet a member of the workspace: the mint identity holds the member-management rights, the end user need not. - add src/support/serenity/semrush-ims-token.js: mintSemrushImsToken(env, log) — client_credentials mint; token endpoint host from IMS_HOST; scope override via SEMRUSH_IMS_TECH_SCOPE; 503 on missing config, 502 on IMS failure; never logs secrets. - controllers/serenity.js addMembers: org-level authorize + validation first, THEN mint the dedicated token, THEN call the transport (no more caller/promise-token path here). - rest-transport addWorkspaceMembers doc updated (bearer = minted dedicated IMS token). - tests: mint unit tests + addMembers uses-minted-token / mint-after-gate / mint-config failure; .env.example documents the new vars. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c989095 commit d95e2c6

6 files changed

Lines changed: 311 additions & 15 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,15 @@ SEO_CLIENT_SECRET=
126126
# silently point a fresh local stack at the live upstream.
127127
# SEMRUSH_PROJECTS_BASE_URL=https://adobe-hackathon.semrush.com
128128

129+
# ── Semrush User Manager provisioning (POST /serenity/members) ────────────────
130+
# Dedicated Semrush IMS technical account whose client_credentials token authorizes
131+
# the workspace member-add call (NOT the calling user's token — this is what lets the
132+
# flow provision a user who is not yet a workspace member). Token endpoint host is
133+
# derived from IMS_HOST; scope defaults to
134+
# openid,AdobeID,user_management_sdk,additional_info.projectedProductContext.
135+
# SEMRUSH_IMS_TECH_ID=
136+
# SEMRUSH_IMS_TECH_SECRET=
137+
# SEMRUSH_IMS_TECH_SCOPE=
138+
129139
# ── Server port (optional, default 3002) ──────────────────────────────────────
130140
# PORT=3001

src/controllers/serenity.js

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
resolveBrandWorkspace,
2525
clearBrandWorkspaceCache,
2626
} from '../support/serenity/workspace-resolver.js';
27+
import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js';
2728
import {
2829
handleListPrompts,
2930
handleCreatePrompts,
@@ -1233,18 +1234,21 @@ function SerenityController(context, log, env) {
12331234

12341235
/**
12351236
* POST /serenity/members — grant one or more users a Semrush workspace role
1236-
* (RBAC write slice; ADR-draft-2 spike). Resolves the brand's workspace via
1237+
* (RBAC write slice; ADR-draft-2/3). Resolves the brand's workspace via
12371238
* `authorize` (the brand's sub-workspace in subworkspace mode, else the org's
1238-
* flat parent workspace) and forwards the caller's IMS token to Semrush's User
1239-
* Manager `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[],
1240-
* role?: string }`; `role` defaults to `role/workspace/viewer`.
1239+
* flat parent workspace) and calls Semrush's User Manager
1240+
* `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[], role?: string }`;
1241+
* `role` defaults to `role/workspace/viewer`.
12411242
*
1242-
* Auth: unchanged pass-through — Semrush authorizes the forwarded token, so the
1243-
* grant only lands if the caller holds member-management rights on the workspace.
1243+
* Auth: the Adobe caller is authorized here at the ORG level (`authorize` →
1244+
* AccessControlUtil), but the OUTBOUND Semrush call is authenticated with a token
1245+
* minted for the DEDICATED Semrush IMS technical account (SEMRUSH_IMS_TECH_*),
1246+
* NOT the caller's own token. This is what lets the grant provision a user who is
1247+
* not yet a member of the workspace — the mint identity holds the member-management
1248+
* rights, so the "user hits 401/403 → provision them" flow can succeed.
12441249
*/
12451250
const addMembers = async (ctx) => {
12461251
try {
1247-
const imsToken = await resolveSemrushImsToken(ctx);
12481252
const auth = await authorize(ctx);
12491253
if (auth.error) {
12501254
return auth.error;
@@ -1257,6 +1261,10 @@ function SerenityController(context, log, env) {
12571261
throw new ErrorWithStatusCode('members must be a non-empty array of user identifiers', 400);
12581262
}
12591263
const role = hasText(body.role) ? body.role : 'role/workspace/viewer';
1264+
// Mint the dedicated Semrush IMS technical-account token (NOT the caller's) so a
1265+
// not-yet-a-member user can still be provisioned. Minted only after the org-level
1266+
// authorize gate + input validation pass, so a bad request never mints a token.
1267+
const imsToken = await mintSemrushImsToken(ctx.env || env, log);
12601268
const transport = buildTransport(ctx, imsToken);
12611269
const result = await transport.addWorkspaceMembers(
12621270
/** @type {string} */ (auth.workspaceId),

src/support/serenity/rest-transport.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,11 +1046,11 @@ export function createSerenityTransport({ env, imsToken }) {
10461046
/**
10471047
* POST /v1/workspaces/{ws}/members — grant one or more users a role on the
10481048
* workspace (Semrush RBAC). Body: `{ members: string[], role: string }`
1049-
* (e.g. role `role/workspace/viewer`). SPIKE (ADR-draft-2): first RBAC write
1050-
* slice — reuses the already-wired User Manager client + IMS-bearer auth; no
1051-
* new secret/config. Auth model is unchanged: the CALLER'S IMS token is
1052-
* forwarded, so the grant succeeds only if that caller holds member-management
1053-
* rights on the workspace (Semrush is the auth boundary, not this proxy).
1049+
* (e.g. role `role/workspace/viewer`). ADR-draft-2/3: RBAC write slice that
1050+
* reuses the already-wired User Manager client. The bearer this transport is
1051+
* built with is the DEDICATED Semrush IMS technical-account token (minted by
1052+
* the controller via SEMRUSH_IMS_TECH_*), NOT the calling user's token — so it
1053+
* can provision a user who is not yet a member of the workspace.
10541054
*
10551055
* `/v1/workspaces/{id}/members` (op `workspace-add-members`) is present in the
10561056
* user-manager-client generated spec with body `{ members: string[]; role: string }`.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// @ts-check
14+
15+
import { hasText } from '@adobe/spacecat-shared-utils';
16+
import { ErrorWithStatusCode } from '../utils.js';
17+
18+
const TOKEN_PATH = '/ims/token/v3';
19+
// Scopes required to manage Semrush workspace members via the User Manager API
20+
// (user_management_sdk is the operative one). Overridable via SEMRUSH_IMS_TECH_SCOPE.
21+
const DEFAULT_SCOPES = 'openid,AdobeID,user_management_sdk,additional_info.projectedProductContext';
22+
23+
/**
24+
* Builds the IMS token endpoint URL from `IMS_HOST` (accepts a bare host such as
25+
* `ims-na1.adobelogin.com` or a full origin). This is only the IMS *host* — the
26+
* dedicated Semrush IMS identity's own credentials come from the SEMRUSH_IMS_TECH_*
27+
* vars below — so stage/prod stay switchable via the env var the service already
28+
* carries, with no extra URL secret.
29+
*
30+
* @param {object} env
31+
* @returns {string} the absolute `/ims/token/v3` URL.
32+
*/
33+
function imsTokenUrl(env) {
34+
const raw = typeof env?.IMS_HOST === 'string' ? env.IMS_HOST.trim() : '';
35+
if (!hasText(raw)) {
36+
throw new ErrorWithStatusCode(
37+
'IMS_HOST is not set; cannot mint the Semrush IMS token',
38+
503,
39+
);
40+
}
41+
const origin = raw.startsWith('http') ? raw : `https://${raw}`;
42+
return new URL(TOKEN_PATH, origin).href;
43+
}
44+
45+
/**
46+
* Mints an IMS access token for the DEDICATED Semrush IMS technical account via the
47+
* `client_credentials` grant, and returns it for use as the bearer on Semrush User
48+
* Manager calls. This deliberately does NOT use the calling user's token: the flow
49+
* must be able to provision a user who is not yet a member of the workspace, so the
50+
* grant runs as this dedicated Semrush IMS identity (which holds the member-management
51+
* rights), not as the end user.
52+
*
53+
* Credentials come from `SEMRUSH_IMS_TECH_ID` / `SEMRUSH_IMS_TECH_SECRET` (Vault:
54+
* dx_mysticat/<env>/api-service). The token endpoint host is derived from `IMS_HOST`;
55+
* scopes default to `DEFAULT_SCOPES` and are overridable via `SEMRUSH_IMS_TECH_SCOPE`.
56+
*
57+
* Never logs the credentials or the minted token — only the IMS-side error code and
58+
* HTTP status on failure.
59+
*
60+
* @param {object} env - runtime env (reads IMS_HOST, SEMRUSH_IMS_TECH_ID,
61+
* SEMRUSH_IMS_TECH_SECRET, optional SEMRUSH_IMS_TECH_SCOPE).
62+
* @param {{ error: (msg: string, meta?: object) => void }} log
63+
* @returns {Promise<string>} the IMS access token (no 'Bearer ' prefix).
64+
*/
65+
export async function mintSemrushImsToken(env, log) {
66+
const clientId = typeof env?.SEMRUSH_IMS_TECH_ID === 'string' ? env.SEMRUSH_IMS_TECH_ID.trim() : '';
67+
const clientSecret = typeof env?.SEMRUSH_IMS_TECH_SECRET === 'string'
68+
? env.SEMRUSH_IMS_TECH_SECRET.trim()
69+
: '';
70+
if (!hasText(clientId) || !hasText(clientSecret)) {
71+
throw new ErrorWithStatusCode(
72+
'SEMRUSH_IMS_TECH_ID and SEMRUSH_IMS_TECH_SECRET must be set to mint the Semrush IMS token',
73+
503,
74+
);
75+
}
76+
const scope = hasText(env?.SEMRUSH_IMS_TECH_SCOPE)
77+
? env.SEMRUSH_IMS_TECH_SCOPE.trim()
78+
: DEFAULT_SCOPES;
79+
const body = new URLSearchParams({
80+
grant_type: 'client_credentials',
81+
client_id: clientId,
82+
client_secret: clientSecret,
83+
scope,
84+
});
85+
86+
// Resolve the endpoint BEFORE the network try/catch so a config error (missing
87+
// IMS_HOST → 503) is not rewrapped as a 502 transport failure.
88+
const url = imsTokenUrl(env);
89+
let response;
90+
try {
91+
response = await fetch(url, {
92+
method: 'POST',
93+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94+
body,
95+
});
96+
} catch (e) {
97+
throw new ErrorWithStatusCode(
98+
`Failed to reach IMS to mint the Semrush IMS token: ${e?.message}`,
99+
502,
100+
);
101+
}
102+
103+
let json = null;
104+
try {
105+
json = await response.json();
106+
} catch { /* non-JSON error body handled below */ }
107+
108+
if (!response.ok || !hasText(json?.access_token)) {
109+
// Do NOT log the credentials or token — only the IMS-side error signal.
110+
log.error('Semrush IMS token mint failed', {
111+
status: response.status,
112+
imsError: typeof json?.error === 'string' ? json.error : '',
113+
});
114+
throw new ErrorWithStatusCode('Failed to mint the Semrush IMS token', 502);
115+
}
116+
return json.access_token;
117+
}

test/controllers/serenity.test.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ describe('SerenityController', () => {
171171
let resolveBrandWorkspaceStub;
172172
let isSerenityActiveStub;
173173
let createTransportStub;
174+
let mintSemrushImsTokenStub;
174175
let resolveBrandUuidStub;
175176
let getBrandAliasesStub;
176177
let getBrandUrlSourcesStub;
@@ -204,6 +205,7 @@ describe('SerenityController', () => {
204205
ensureSubworkspaceStub = sinon.stub().resolves(SUBWS);
205206
clearBrandWorkspaceCacheStub = sinon.stub();
206207
createTransportStub = sinon.stub().returns({ name: 'transport' });
208+
mintSemrushImsTokenStub = sinon.stub().resolves('semrush-ims-tech-token');
207209
resolveBrandUuidStub = sinon.stub().resolves(BRAND);
208210
getBrandAliasesStub = sinon.stub().resolves([]);
209211
getBrandUrlSourcesStub = sinon.stub()
@@ -245,6 +247,9 @@ describe('SerenityController', () => {
245247
resolveBrandWorkspace: resolveBrandWorkspaceStub,
246248
clearBrandWorkspaceCache: clearBrandWorkspaceCacheStub,
247249
},
250+
'../../src/support/serenity/semrush-ims-token.js': {
251+
mintSemrushImsToken: mintSemrushImsTokenStub,
252+
},
248253
'../../src/support/serenity/handlers/prompts.js': {
249254
handleListPrompts: handlers.handleListPrompts,
250255
handleCreatePrompts: handlers.handleCreatePrompts,
@@ -1786,7 +1791,7 @@ describe('SerenityController', () => {
17861791
});
17871792
});
17881793

1789-
describe('addMembers (RBAC workspace member grant — ADR-draft-2 spike)', () => {
1794+
describe('addMembers (RBAC workspace member grant — minted Semrush IMS token)', () => {
17901795
let addWorkspaceMembersStub;
17911796

17921797
beforeEach(() => {
@@ -1810,6 +1815,17 @@ describe('SerenityController', () => {
18101815
expect(await readBody(response)).to.deep.equal({ consumedUnits: -1 });
18111816
});
18121817

1818+
it('authenticates with the MINTED Semrush IMS token, not the caller token', async () => {
1819+
const controller = SerenityController({ env: {} }, fakeLog(), {});
1820+
await controller.addMembers(fakeContext({
1821+
data: { members: ['a@adobe.com'] },
1822+
}));
1823+
// The dedicated Semrush IMS token is minted and passed to the transport.
1824+
expect(mintSemrushImsTokenStub).to.have.been.calledOnce;
1825+
expect(createTransportStub.firstCall.args[0])
1826+
.to.have.property('imsToken', 'semrush-ims-tech-token');
1827+
});
1828+
18131829
it('defaults the role to role/workspace/viewer when none is supplied', async () => {
18141830
const controller = SerenityController({ env: {} }, fakeLog(), {});
18151831
const response = await controller.addMembers(fakeContext({
@@ -1842,12 +1858,14 @@ describe('SerenityController', () => {
18421858
});
18431859
});
18441860

1845-
it('rejects a missing members array with 400 and never calls upstream', async () => {
1861+
it('rejects a missing members array with 400 — no token minted, no upstream call', async () => {
18461862
const controller = SerenityController({ env: {} }, fakeLog(), {});
18471863
const response = await controller.addMembers(fakeContext({
18481864
data: { role: 'role/workspace/viewer' },
18491865
}));
18501866
expect(response.status).to.equal(400);
1867+
// Mint happens only after the authorize gate + input validation pass.
1868+
expect(mintSemrushImsTokenStub).to.not.have.been.called;
18511869
expect(addWorkspaceMembersStub).to.not.have.been.called;
18521870
});
18531871

@@ -1860,13 +1878,27 @@ describe('SerenityController', () => {
18601878
expect(addWorkspaceMembersStub).to.not.have.been.called;
18611879
});
18621880

1863-
it('propagates an authorize failure (no org access) as 403 without calling upstream', async () => {
1881+
it('propagates an authorize failure (no org access) as 403 — no token minted', async () => {
18641882
accessControlHasAccessStub.resolves(false);
18651883
const controller = SerenityController({ env: {} }, fakeLog(), {});
18661884
const response = await controller.addMembers(fakeContext({
18671885
data: { members: ['a@adobe.com'] },
18681886
}));
18691887
expect(response.status).to.equal(403);
1888+
expect(mintSemrushImsTokenStub).to.not.have.been.called;
1889+
expect(addWorkspaceMembersStub).to.not.have.been.called;
1890+
});
1891+
1892+
it('surfaces a token-mint config failure (missing SEMRUSH_IMS_TECH_*) as 503', async () => {
1893+
mintSemrushImsTokenStub.rejects(
1894+
new ErrorWithStatusCode('SEMRUSH_IMS_TECH_ID and SEMRUSH_IMS_TECH_SECRET must be set', 503),
1895+
);
1896+
const controller = SerenityController({ env: {} }, fakeLog(), {});
1897+
const response = await controller.addMembers(fakeContext({
1898+
data: { members: ['a@adobe.com'] },
1899+
}));
1900+
expect(response.status).to.equal(503);
1901+
// Token mint failed → never reach the upstream member add.
18701902
expect(addWorkspaceMembersStub).to.not.have.been.called;
18711903
});
18721904

0 commit comments

Comments
 (0)