From 45d2e581f5ab6a198bc759dddfbf7b8f38f75d35 Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Fri, 31 Jul 2026 11:33:16 +0530 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20spike=20=E2=80=94=20grant=20Semrush?= =?UTF-8?q?=20workspace=20members=20via=20reused=20Serenity=20User=20Manag?= =?UTF-8?q?er=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPIKE (throwaway proof, ADR-draft-2): add POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members to grant users a Semrush workspace role, reusing the existing Serenity User Manager transport, IMS-bearer auth, and secrets — no new config. - rest-transport.js: addWorkspaceMembers(workspaceId, members, role) via the wired `users` client (POST /v1/workspaces/{id}/members; body { members, role }). Present in user-manager-client 1.5.0. - controllers/serenity.js: addMembers handler (resolveSemrushImsToken -> authorize -> resolveBrandWorkspace -> transport); validates members[], role defaults role/workspace/viewer. - routes/index.js + facs-capabilities.js (llmo/can_configure) + route/controller unit + route tests. Caller's IMS token is forwarded as-is (Semrush is the auth boundary). Open: correct capability level and whether non-@semrush.com emails require IMS->Semrush identity mapping — verify in dev. Co-Authored-By: Claude Opus 4.8 --- src/controllers/serenity.js | 41 +++++++++++ src/routes/facs-capabilities.js | 4 ++ src/routes/index.js | 1 + src/support/serenity/rest-transport.js | 20 ++++++ test/controllers/serenity.test.js | 94 ++++++++++++++++++++++++++ test/routes/index.test.js | 1 + 6 files changed, 161 insertions(+) diff --git a/src/controllers/serenity.js b/src/controllers/serenity.js index 43e85c2748..d76c8eb53e 100644 --- a/src/controllers/serenity.js +++ b/src/controllers/serenity.js @@ -1156,6 +1156,46 @@ function SerenityController(context, log, env) { } }; + /** + * POST /serenity/members — grant one or more users a Semrush workspace role + * (RBAC write slice; ADR-draft-2 spike). Resolves the brand's workspace via + * `authorize` (the brand's sub-workspace in subworkspace mode, else the org's + * flat parent workspace) and forwards the caller's IMS token to Semrush's User + * Manager `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[], + * role?: string }`; `role` defaults to `role/workspace/viewer`. + * + * Auth: unchanged pass-through — Semrush authorizes the forwarded token, so the + * grant only lands if the caller holds member-management rights on the workspace. + */ + const addMembers = async (ctx) => { + try { + const imsToken = await resolveSemrushImsToken(ctx); + const auth = await authorize(ctx); + if (auth.error) { + return auth.error; + } + const body = ctx.data || {}; + const members = Array.isArray(body.members) + ? body.members.filter((m) => hasText(m)) + : []; + if (members.length === 0) { + throw new ErrorWithStatusCode('members must be a non-empty array of user identifiers', 400); + } + const role = hasText(body.role) ? body.role : 'role/workspace/viewer'; + const transport = buildTransport(ctx, imsToken); + const result = await transport.addWorkspaceMembers( + /** @type {string} */ (auth.workspaceId), + members, + role, + ); + // Semrush may answer 2xx with an empty body; echo the grant so the spike + // caller sees what landed. + return createResponse(isNonEmptyObject(result) ? result : { members, role }, 200); + } catch (e) { + return mapError(e, log); + } + }; + /** * POST /serenity/activate — flips a brand into subworkspace mode (design flow 5): * ensure the subworkspace, then per caller-supplied market create a draft, @@ -1710,6 +1750,7 @@ function SerenityController(context, log, env) { listOrgModels, listOrgLanguages, updateModels, + addMembers, activate, deactivate, }; diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index 458e609d8c..f94ff8871b 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -617,6 +617,10 @@ const routeFacsCapabilities = { 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/tags': 'llmo/can_configure', 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/serenity/tags/:tagId': 'llmo/can_configure', 'PUT /v2/orgs/:spaceCatId/brands/:brandId/serenity/models': 'llmo/can_configure', + // SPIKE (ADR-draft-2): RBAC member grant. Mirrors sibling write ops as + // can_configure; the correct level (can_configure vs can_manage_users) is + // an OPEN design question tied to the add-member auth decision. + 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': 'llmo/can_configure', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'llmo/can_configure', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'llmo/can_configure', // Prompt suitability check — body-based mutation against the brand diff --git a/src/routes/index.js b/src/routes/index.js index 45d4a86460..ad024b271f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -282,6 +282,7 @@ export default function getRouteHandlers( 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/source-visibility-headline': elementsController.getSourceVisibilityHeadline, // Brand-independent Semrush language catalog (add-brand wizard language picker). 'GET /v2/orgs/:spaceCatId/serenity/languages': serenityController.listOrgLanguages, + 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': serenityController.addMembers, 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': serenityController.activate, 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': serenityController.deactivate, 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts': brandsController.listPromptsByBrand, diff --git a/src/support/serenity/rest-transport.js b/src/support/serenity/rest-transport.js index 6016b1af61..46642a6817 100644 --- a/src/support/serenity/rest-transport.js +++ b/src/support/serenity/rest-transport.js @@ -741,6 +741,26 @@ export function createSerenityTransport({ env, imsToken }) { )); }, + /** + * POST /v1/workspaces/{ws}/members — grant one or more users a role on the + * workspace (Semrush RBAC). Body: `{ members: string[], role: string }` + * (e.g. role `role/workspace/viewer`). SPIKE (ADR-draft-2): first RBAC write + * slice — reuses the already-wired User Manager client + IMS-bearer auth; no + * new secret/config. Auth model is unchanged: the CALLER'S IMS token is + * forwarded, so the grant succeeds only if that caller holds member-management + * rights on the workspace (Semrush is the auth boundary, not this proxy). + * + * NOTE: `/v1/workspaces/{id}/members` must exist in the user-manager-client + * (1.5.0) generated spec for the typed `.POST` to type-check; verify on the + * branch (blocking tsc gate). If absent, bump the client or drop to a raw call. + */ + async addWorkspaceMembers(workspaceId, members, role) { + return unwrap('POST', await users.POST( + '/v1/workspaces/{id}/members', + { params: { path: { id: workspaceId } }, body: { members, role } }, + )); + }, + /** * GET /v1/workspaces/{ws}/status — poll until `created` after a subworkspace * create (creating projects against `not ready` can 500). diff --git a/test/controllers/serenity.test.js b/test/controllers/serenity.test.js index 5d51c5388f..81047ecf45 100644 --- a/test/controllers/serenity.test.js +++ b/test/controllers/serenity.test.js @@ -1767,6 +1767,100 @@ describe('SerenityController', () => { }); }); + describe('addMembers (RBAC workspace member grant — ADR-draft-2 spike)', () => { + let addWorkspaceMembersStub; + + beforeEach(() => { + // Default transport stub returns { name: 'transport' } with no member method; + // override it so buildTransport yields a transport exposing addWorkspaceMembers. + addWorkspaceMembersStub = sinon.stub().resolves({ consumedUnits: -1 }); + createTransportStub.returns({ addWorkspaceMembers: addWorkspaceMembersStub }); + }); + + it('grants the given members the requested role on the brand workspace', async () => { + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['ppatwal@adobe.com'], role: 'role/workspace/viewer' }, + })); + expect(response.status).to.equal(200); + expect(addWorkspaceMembersStub).to.have.been.calledOnceWithExactly( + WORKSPACE, + ['ppatwal@adobe.com'], + 'role/workspace/viewer', + ); + expect(await readBody(response)).to.deep.equal({ consumedUnits: -1 }); + }); + + it('defaults the role to role/workspace/viewer when none is supplied', async () => { + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + expect(response.status).to.equal(200); + expect(addWorkspaceMembersStub.firstCall.args[2]).to.equal('role/workspace/viewer'); + }); + + it('targets the brand sub-workspace id when the brand is in subworkspace mode', async () => { + resolveBrandWorkspaceStub.resolves({ + mode: 'subworkspace', workspaceId: SUBWS, parentWorkspaceId: WORKSPACE, + }); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'], role: 'role/workspace/viewer' }, + })); + expect(addWorkspaceMembersStub.firstCall.args[0]).to.equal(SUBWS); + }); + + it('echoes { members, role } when the upstream returns an empty body', async () => { + addWorkspaceMembersStub.resolves(null); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'], role: 'role/workspace/editor' }, + })); + expect(response.status).to.equal(200); + expect(await readBody(response)).to.deep.equal({ + members: ['a@adobe.com'], role: 'role/workspace/editor', + }); + }); + + it('rejects a missing members array with 400 and never calls upstream', async () => { + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { role: 'role/workspace/viewer' }, + })); + expect(response.status).to.equal(400); + expect(addWorkspaceMembersStub).to.not.have.been.called; + }); + + it('drops empty-string members and 400s when none remain', async () => { + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['', ''] }, + })); + expect(response.status).to.equal(400); + expect(addWorkspaceMembersStub).to.not.have.been.called; + }); + + it('propagates an authorize failure (no org access) as 403 without calling upstream', async () => { + accessControlHasAccessStub.resolves(false); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + expect(response.status).to.equal(403); + expect(addWorkspaceMembersStub).to.not.have.been.called; + }); + + it('routes an upstream Semrush error through mapError (403 → 403)', async () => { + addWorkspaceMembersStub.rejects(new MockTransportError(403, 'forbidden by semrush')); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + expect(response.status).to.equal(403); + }); + }); + describe('activate / deactivate', () => { it('activate 401s (IMS-only) before any provisioning when the caller is not IMS-authenticated', async () => { const controller = SerenityController({ env: {} }, fakeLog(), {}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 32c9be8563..3489b4a881 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -944,6 +944,7 @@ describe('getRouteHandlers', () => { 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/competitor-summary', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/kpi-headlines', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/source-visibility-headline', + 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate', 'GET /v2/orgs/:spaceCatId/sites/:siteId/brand', From 7608a17f3dc2754147b1fb43605989177c2d7b5a Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Fri, 31 Jul 2026 12:13:20 +0530 Subject: [PATCH 2/5] fix: add serenity/members to required-capabilities S2S map The route-coverage test requires every route in routes/index.js to appear in routeRequiredCapabilities or INTERNAL_ROUTES. Classify the new members grant as organization:write, matching sibling serenity write ops (activate/deactivate). Co-Authored-By: Claude Opus 4.8 --- src/routes/required-capabilities.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index da94ad94b6..0c93208ca2 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -327,6 +327,7 @@ const routeRequiredCapabilities = { 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/kpi-headlines': 'brand:read', // eslint-disable-next-line max-len 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/source-visibility-headline': 'brand:read', + 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/members': 'organization:write', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'organization:write', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'organization:write', 'GET /v2/orgs/:spaceCatId/sites/:siteId/brand': 'organization:read', From 29e8f6fe7917c8a7d0aefad79a8a429afffe2a0a Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Fri, 31 Jul 2026 12:24:56 +0530 Subject: [PATCH 3/5] fix(serenity): add JSDoc @param types to addWorkspaceMembers for strict type-check The new type-check:strict tier (noImplicitAny) flagged the spike transport method's untyped params. Annotate workspaceId/members/role per the serenity dir's JSDoc rule. Co-Authored-By: Claude Opus 4.8 --- src/support/serenity/rest-transport.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/support/serenity/rest-transport.js b/src/support/serenity/rest-transport.js index 01a702b960..adb9ca332b 100644 --- a/src/support/serenity/rest-transport.js +++ b/src/support/serenity/rest-transport.js @@ -1052,9 +1052,12 @@ export function createSerenityTransport({ env, imsToken }) { * forwarded, so the grant succeeds only if that caller holds member-management * rights on the workspace (Semrush is the auth boundary, not this proxy). * - * NOTE: `/v1/workspaces/{id}/members` must exist in the user-manager-client - * (1.5.0) generated spec for the typed `.POST` to type-check; verify on the - * branch (blocking tsc gate). If absent, bump the client or drop to a raw call. + * `/v1/workspaces/{id}/members` (op `workspace-add-members`) is present in the + * user-manager-client generated spec with body `{ members: string[]; role: string }`. + * + * @param {string} workspaceId + * @param {string[]} members - user identifiers (emails) to grant the role. + * @param {string} role - Semrush role, e.g. `role/workspace/viewer`. */ async addWorkspaceMembers(workspaceId, members, role) { return unwrap('POST', await users.POST( From d95e2c699f5e9e16217ac204cab4ea1555c1dcdc Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Tue, 4 Aug 2026 13:43:19 +0530 Subject: [PATCH 4/5] feat(serenity): mint dedicated Semrush IMS token for member-add instead of caller token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 10 ++ src/controllers/serenity.js | 22 ++- src/support/serenity/rest-transport.js | 10 +- src/support/serenity/semrush-ims-token.js | 117 ++++++++++++++++ test/controllers/serenity.test.js | 38 +++++- .../serenity/semrush-ims-token.test.js | 129 ++++++++++++++++++ 6 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 src/support/serenity/semrush-ims-token.js create mode 100644 test/support/serenity/semrush-ims-token.test.js diff --git a/.env.example b/.env.example index 35846a304d..ed80de5f8c 100644 --- a/.env.example +++ b/.env.example @@ -126,5 +126,15 @@ SEO_CLIENT_SECRET= # silently point a fresh local stack at the live upstream. # SEMRUSH_PROJECTS_BASE_URL=https://adobe-hackathon.semrush.com +# ── Semrush User Manager provisioning (POST /serenity/members) ──────────────── +# Dedicated Semrush IMS technical account whose client_credentials token authorizes +# the workspace member-add call (NOT the calling user's token — this is what lets the +# flow provision a user who is not yet a workspace member). Token endpoint host is +# derived from IMS_HOST; scope defaults to +# openid,AdobeID,user_management_sdk,additional_info.projectedProductContext. +# SEMRUSH_IMS_TECH_ID= +# SEMRUSH_IMS_TECH_SECRET= +# SEMRUSH_IMS_TECH_SCOPE= + # ── Server port (optional, default 3002) ────────────────────────────────────── # PORT=3001 \ No newline at end of file diff --git a/src/controllers/serenity.js b/src/controllers/serenity.js index 445c1bcdd2..53f4648ee2 100644 --- a/src/controllers/serenity.js +++ b/src/controllers/serenity.js @@ -24,6 +24,7 @@ import { resolveBrandWorkspace, clearBrandWorkspaceCache, } from '../support/serenity/workspace-resolver.js'; +import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js'; import { handleListPrompts, handleCreatePrompts, @@ -1233,18 +1234,21 @@ function SerenityController(context, log, env) { /** * POST /serenity/members — grant one or more users a Semrush workspace role - * (RBAC write slice; ADR-draft-2 spike). Resolves the brand's workspace via + * (RBAC write slice; ADR-draft-2/3). Resolves the brand's workspace via * `authorize` (the brand's sub-workspace in subworkspace mode, else the org's - * flat parent workspace) and forwards the caller's IMS token to Semrush's User - * Manager `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[], - * role?: string }`; `role` defaults to `role/workspace/viewer`. + * flat parent workspace) and calls Semrush's User Manager + * `POST /v1/workspaces/{ws}/members`. Body: `{ members: string[], role?: string }`; + * `role` defaults to `role/workspace/viewer`. * - * Auth: unchanged pass-through — Semrush authorizes the forwarded token, so the - * grant only lands if the caller holds member-management rights on the workspace. + * Auth: the Adobe caller is authorized here at the ORG level (`authorize` → + * AccessControlUtil), but the OUTBOUND Semrush call is authenticated with a token + * minted for the DEDICATED Semrush IMS technical account (SEMRUSH_IMS_TECH_*), + * NOT the caller's own token. This is what lets the grant provision a user who is + * not yet a member of the workspace — the mint identity holds the member-management + * rights, so the "user hits 401/403 → provision them" flow can succeed. */ const addMembers = async (ctx) => { try { - const imsToken = await resolveSemrushImsToken(ctx); const auth = await authorize(ctx); if (auth.error) { return auth.error; @@ -1257,6 +1261,10 @@ function SerenityController(context, log, env) { throw new ErrorWithStatusCode('members must be a non-empty array of user identifiers', 400); } const role = hasText(body.role) ? body.role : 'role/workspace/viewer'; + // Mint the dedicated Semrush IMS technical-account token (NOT the caller's) so a + // not-yet-a-member user can still be provisioned. Minted only after the org-level + // authorize gate + input validation pass, so a bad request never mints a token. + const imsToken = await mintSemrushImsToken(ctx.env || env, log); const transport = buildTransport(ctx, imsToken); const result = await transport.addWorkspaceMembers( /** @type {string} */ (auth.workspaceId), diff --git a/src/support/serenity/rest-transport.js b/src/support/serenity/rest-transport.js index adb9ca332b..bfa88c081f 100644 --- a/src/support/serenity/rest-transport.js +++ b/src/support/serenity/rest-transport.js @@ -1046,11 +1046,11 @@ export function createSerenityTransport({ env, imsToken }) { /** * POST /v1/workspaces/{ws}/members — grant one or more users a role on the * workspace (Semrush RBAC). Body: `{ members: string[], role: string }` - * (e.g. role `role/workspace/viewer`). SPIKE (ADR-draft-2): first RBAC write - * slice — reuses the already-wired User Manager client + IMS-bearer auth; no - * new secret/config. Auth model is unchanged: the CALLER'S IMS token is - * forwarded, so the grant succeeds only if that caller holds member-management - * rights on the workspace (Semrush is the auth boundary, not this proxy). + * (e.g. role `role/workspace/viewer`). ADR-draft-2/3: RBAC write slice that + * reuses the already-wired User Manager client. The bearer this transport is + * built with is the DEDICATED Semrush IMS technical-account token (minted by + * the controller via SEMRUSH_IMS_TECH_*), NOT the calling user's token — so it + * can provision a user who is not yet a member of the workspace. * * `/v1/workspaces/{id}/members` (op `workspace-add-members`) is present in the * user-manager-client generated spec with body `{ members: string[]; role: string }`. diff --git a/src/support/serenity/semrush-ims-token.js b/src/support/serenity/semrush-ims-token.js new file mode 100644 index 0000000000..269ce04e45 --- /dev/null +++ b/src/support/serenity/semrush-ims-token.js @@ -0,0 +1,117 @@ +/* + * Copyright 2026 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. + */ + +// @ts-check + +import { hasText } from '@adobe/spacecat-shared-utils'; +import { ErrorWithStatusCode } from '../utils.js'; + +const TOKEN_PATH = '/ims/token/v3'; +// Scopes required to manage Semrush workspace members via the User Manager API +// (user_management_sdk is the operative one). Overridable via SEMRUSH_IMS_TECH_SCOPE. +const DEFAULT_SCOPES = 'openid,AdobeID,user_management_sdk,additional_info.projectedProductContext'; + +/** + * Builds the IMS token endpoint URL from `IMS_HOST` (accepts a bare host such as + * `ims-na1.adobelogin.com` or a full origin). This is only the IMS *host* — the + * dedicated Semrush IMS identity's own credentials come from the SEMRUSH_IMS_TECH_* + * vars below — so stage/prod stay switchable via the env var the service already + * carries, with no extra URL secret. + * + * @param {object} env + * @returns {string} the absolute `/ims/token/v3` URL. + */ +function imsTokenUrl(env) { + const raw = typeof env?.IMS_HOST === 'string' ? env.IMS_HOST.trim() : ''; + if (!hasText(raw)) { + throw new ErrorWithStatusCode( + 'IMS_HOST is not set; cannot mint the Semrush IMS token', + 503, + ); + } + const origin = raw.startsWith('http') ? raw : `https://${raw}`; + return new URL(TOKEN_PATH, origin).href; +} + +/** + * Mints an IMS access token for the DEDICATED Semrush IMS technical account via the + * `client_credentials` grant, and returns it for use as the bearer on Semrush User + * Manager calls. This deliberately does NOT use the calling user's token: the flow + * must be able to provision a user who is not yet a member of the workspace, so the + * grant runs as this dedicated Semrush IMS identity (which holds the member-management + * rights), not as the end user. + * + * Credentials come from `SEMRUSH_IMS_TECH_ID` / `SEMRUSH_IMS_TECH_SECRET` (Vault: + * dx_mysticat//api-service). The token endpoint host is derived from `IMS_HOST`; + * scopes default to `DEFAULT_SCOPES` and are overridable via `SEMRUSH_IMS_TECH_SCOPE`. + * + * Never logs the credentials or the minted token — only the IMS-side error code and + * HTTP status on failure. + * + * @param {object} env - runtime env (reads IMS_HOST, SEMRUSH_IMS_TECH_ID, + * SEMRUSH_IMS_TECH_SECRET, optional SEMRUSH_IMS_TECH_SCOPE). + * @param {{ error: (msg: string, meta?: object) => void }} log + * @returns {Promise} the IMS access token (no 'Bearer ' prefix). + */ +export async function mintSemrushImsToken(env, log) { + const clientId = typeof env?.SEMRUSH_IMS_TECH_ID === 'string' ? env.SEMRUSH_IMS_TECH_ID.trim() : ''; + const clientSecret = typeof env?.SEMRUSH_IMS_TECH_SECRET === 'string' + ? env.SEMRUSH_IMS_TECH_SECRET.trim() + : ''; + if (!hasText(clientId) || !hasText(clientSecret)) { + throw new ErrorWithStatusCode( + 'SEMRUSH_IMS_TECH_ID and SEMRUSH_IMS_TECH_SECRET must be set to mint the Semrush IMS token', + 503, + ); + } + const scope = hasText(env?.SEMRUSH_IMS_TECH_SCOPE) + ? env.SEMRUSH_IMS_TECH_SCOPE.trim() + : DEFAULT_SCOPES; + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope, + }); + + // Resolve the endpoint BEFORE the network try/catch so a config error (missing + // IMS_HOST → 503) is not rewrapped as a 502 transport failure. + const url = imsTokenUrl(env); + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + } catch (e) { + throw new ErrorWithStatusCode( + `Failed to reach IMS to mint the Semrush IMS token: ${e?.message}`, + 502, + ); + } + + let json = null; + try { + json = await response.json(); + } catch { /* non-JSON error body handled below */ } + + if (!response.ok || !hasText(json?.access_token)) { + // Do NOT log the credentials or token — only the IMS-side error signal. + log.error('Semrush IMS token mint failed', { + status: response.status, + imsError: typeof json?.error === 'string' ? json.error : '', + }); + throw new ErrorWithStatusCode('Failed to mint the Semrush IMS token', 502); + } + return json.access_token; +} diff --git a/test/controllers/serenity.test.js b/test/controllers/serenity.test.js index f06bbd1563..75d7a5e29c 100644 --- a/test/controllers/serenity.test.js +++ b/test/controllers/serenity.test.js @@ -171,6 +171,7 @@ describe('SerenityController', () => { let resolveBrandWorkspaceStub; let isSerenityActiveStub; let createTransportStub; + let mintSemrushImsTokenStub; let resolveBrandUuidStub; let getBrandAliasesStub; let getBrandUrlSourcesStub; @@ -204,6 +205,7 @@ describe('SerenityController', () => { ensureSubworkspaceStub = sinon.stub().resolves(SUBWS); clearBrandWorkspaceCacheStub = sinon.stub(); createTransportStub = sinon.stub().returns({ name: 'transport' }); + mintSemrushImsTokenStub = sinon.stub().resolves('semrush-ims-tech-token'); resolveBrandUuidStub = sinon.stub().resolves(BRAND); getBrandAliasesStub = sinon.stub().resolves([]); getBrandUrlSourcesStub = sinon.stub() @@ -245,6 +247,9 @@ describe('SerenityController', () => { resolveBrandWorkspace: resolveBrandWorkspaceStub, clearBrandWorkspaceCache: clearBrandWorkspaceCacheStub, }, + '../../src/support/serenity/semrush-ims-token.js': { + mintSemrushImsToken: mintSemrushImsTokenStub, + }, '../../src/support/serenity/handlers/prompts.js': { handleListPrompts: handlers.handleListPrompts, handleCreatePrompts: handlers.handleCreatePrompts, @@ -1786,7 +1791,7 @@ describe('SerenityController', () => { }); }); - describe('addMembers (RBAC workspace member grant — ADR-draft-2 spike)', () => { + describe('addMembers (RBAC workspace member grant — minted Semrush IMS token)', () => { let addWorkspaceMembersStub; beforeEach(() => { @@ -1810,6 +1815,17 @@ describe('SerenityController', () => { expect(await readBody(response)).to.deep.equal({ consumedUnits: -1 }); }); + it('authenticates with the MINTED Semrush IMS token, not the caller token', async () => { + const controller = SerenityController({ env: {} }, fakeLog(), {}); + await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + // The dedicated Semrush IMS token is minted and passed to the transport. + expect(mintSemrushImsTokenStub).to.have.been.calledOnce; + expect(createTransportStub.firstCall.args[0]) + .to.have.property('imsToken', 'semrush-ims-tech-token'); + }); + it('defaults the role to role/workspace/viewer when none is supplied', async () => { const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.addMembers(fakeContext({ @@ -1842,12 +1858,14 @@ describe('SerenityController', () => { }); }); - it('rejects a missing members array with 400 and never calls upstream', async () => { + it('rejects a missing members array with 400 — no token minted, no upstream call', async () => { const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.addMembers(fakeContext({ data: { role: 'role/workspace/viewer' }, })); expect(response.status).to.equal(400); + // Mint happens only after the authorize gate + input validation pass. + expect(mintSemrushImsTokenStub).to.not.have.been.called; expect(addWorkspaceMembersStub).to.not.have.been.called; }); @@ -1860,13 +1878,27 @@ describe('SerenityController', () => { expect(addWorkspaceMembersStub).to.not.have.been.called; }); - it('propagates an authorize failure (no org access) as 403 without calling upstream', async () => { + it('propagates an authorize failure (no org access) as 403 — no token minted', async () => { accessControlHasAccessStub.resolves(false); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.addMembers(fakeContext({ data: { members: ['a@adobe.com'] }, })); expect(response.status).to.equal(403); + expect(mintSemrushImsTokenStub).to.not.have.been.called; + expect(addWorkspaceMembersStub).to.not.have.been.called; + }); + + it('surfaces a token-mint config failure (missing SEMRUSH_IMS_TECH_*) as 503', async () => { + mintSemrushImsTokenStub.rejects( + new ErrorWithStatusCode('SEMRUSH_IMS_TECH_ID and SEMRUSH_IMS_TECH_SECRET must be set', 503), + ); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + expect(response.status).to.equal(503); + // Token mint failed → never reach the upstream member add. expect(addWorkspaceMembersStub).to.not.have.been.called; }); diff --git a/test/support/serenity/semrush-ims-token.test.js b/test/support/serenity/semrush-ims-token.test.js new file mode 100644 index 0000000000..8038f327fd --- /dev/null +++ b/test/support/serenity/semrush-ims-token.test.js @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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 { use, expect } from 'chai'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import { mintSemrushImsToken } from '../../../src/support/serenity/semrush-ims-token.js'; + +use(sinonChai); + +const ENV = { + IMS_HOST: 'ims-na1-stg1.adobelogin.com', + SEMRUSH_IMS_TECH_ID: 'tech-client-id', + SEMRUSH_IMS_TECH_SECRET: 'tech-client-secret', +}; + +function fakeResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: sinon.stub().resolves(body), + }; +} + +function fakeLog() { + return { error: sinon.stub() }; +} + +describe('mintSemrushImsToken', () => { + let fetchStub; + + beforeEach(() => { + fetchStub = sinon.stub(globalThis, 'fetch'); + }); + + afterEach(() => sinon.restore()); + + it('mints a token via client_credentials and returns the access_token', async () => { + fetchStub.resolves(fakeResponse(200, { access_token: 'minted-abc', token_type: 'bearer' })); + + const token = await mintSemrushImsToken(ENV, fakeLog()); + + expect(token).to.equal('minted-abc'); + expect(fetchStub).to.have.been.calledOnce; + const [url, opts] = fetchStub.firstCall.args; + expect(url).to.equal('https://ims-na1-stg1.adobelogin.com/ims/token/v3'); + expect(opts.method).to.equal('POST'); + expect(opts.headers['Content-Type']).to.equal('application/x-www-form-urlencoded'); + const body = opts.body.toString(); + expect(body).to.include('grant_type=client_credentials'); + expect(body).to.include('client_id=tech-client-id'); + expect(body).to.include('client_secret=tech-client-secret'); + // Default scopes include the operative user_management_sdk scope. + expect(body).to.include('user_management_sdk'); + }); + + it('accepts a full-origin IMS_HOST', async () => { + fetchStub.resolves(fakeResponse(200, { access_token: 't' })); + await mintSemrushImsToken({ ...ENV, IMS_HOST: 'https://ims-na1.adobelogin.com' }, fakeLog()); + expect(fetchStub.firstCall.args[0]).to.equal('https://ims-na1.adobelogin.com/ims/token/v3'); + }); + + it('honours a SEMRUSH_IMS_TECH_SCOPE override', async () => { + fetchStub.resolves(fakeResponse(200, { access_token: 't' })); + await mintSemrushImsToken({ ...ENV, SEMRUSH_IMS_TECH_SCOPE: 'openid,AdobeID' }, fakeLog()); + expect(fetchStub.firstCall.args[1].body.toString()).to.include('scope=openid%2CAdobeID'); + }); + + it('throws 503 when the tech credentials are missing (no fetch)', async () => { + let err; + try { + await mintSemrushImsToken({ IMS_HOST: ENV.IMS_HOST }, fakeLog()); + } catch (e) { + err = e; + } + expect(err?.status).to.equal(503); + expect(fetchStub).to.not.have.been.called; + }); + + it('throws 503 when IMS_HOST is missing', async () => { + let err; + try { + await mintSemrushImsToken( + { SEMRUSH_IMS_TECH_ID: 'x', SEMRUSH_IMS_TECH_SECRET: 'y' }, + fakeLog(), + ); + } catch (e) { + err = e; + } + expect(err?.status).to.equal(503); + expect(fetchStub).to.not.have.been.called; + }); + + it('throws 502 and logs the IMS error when the response has no access_token', async () => { + const log = fakeLog(); + fetchStub.resolves(fakeResponse(400, { error: 'invalid_client' })); + + let err; + try { + await mintSemrushImsToken(ENV, log); + } catch (e) { + err = e; + } + expect(err?.status).to.equal(502); + expect(log.error).to.have.been.calledOnce; + // The IMS error code is surfaced to the log, never the credentials/token. + expect(log.error.firstCall.args[1]).to.include({ status: 400, imsError: 'invalid_client' }); + }); + + it('throws 502 when the fetch itself rejects (network error)', async () => { + fetchStub.rejects(new Error('econnrefused')); + let err; + try { + await mintSemrushImsToken(ENV, fakeLog()); + } catch (e) { + err = e; + } + expect(err?.status).to.equal(502); + }); +}); From 3cf3cd63acadc48efcab96335fb6868de0ccad18 Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Tue, 4 Aug 2026 18:27:51 +0530 Subject: [PATCH 5/5] feat(serenity): auto-provision workspace member on read 401/403 (flag-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productizes the member-add mechanism as the real flow: when a brand-scoped Semrush data read fails because the caller is not yet a workspace member (401/403), provision them (viewer) using the dedicated Semrush IMS token and retry the read once. - add src/support/serenity/member-autoprovision.js: withMemberAutoProvision (single retry, best-effort — a failed grant surfaces the original read error; no-op when disabled / missing ids / non-401-403) + isSemrushMembershipDenied. - controllers/serenity.js: wrap the 5 brand-scoped reads (prompts, markets, getMarket, tags, models) via readWithProvision; gate on SERENITY_MEMBER_AUTOPROVISION (default off) + the existing serenity-active/workspace gate; resolve caller email. - mapError: surface upstream 422 as unprocessableEntity (was flattened to 502); the limit/emails body stays server-side only. - utils.js: resolveCallerEmail. .env.example documents the flag. - tests: member-autoprovision (9), addMembers 422 passthrough, read-provision wiring (3). The standalone POST /serenity/members endpoint is kept for testing and must be removed before merge. Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 + src/controllers/serenity.js | 74 ++++++--- src/support/serenity/member-autoprovision.js | 101 ++++++++++++ src/support/utils.js | 16 ++ test/controllers/serenity.test.js | 81 ++++++++++ .../serenity/member-autoprovision.test.js | 148 ++++++++++++++++++ 6 files changed, 404 insertions(+), 20 deletions(-) create mode 100644 src/support/serenity/member-autoprovision.js create mode 100644 test/support/serenity/member-autoprovision.test.js diff --git a/.env.example b/.env.example index ed80de5f8c..92514e3105 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,10 @@ SEO_CLIENT_SECRET= # SEMRUSH_IMS_TECH_ID= # SEMRUSH_IMS_TECH_SECRET= # SEMRUSH_IMS_TECH_SCOPE= +# When 'true', a brand-scoped Semrush READ that 401/403s because the caller is not yet a +# workspace member auto-provisions them (viewer) via the token above, then retries once. +# Default off — enabling is a config flip once the dedicated IMS account is wired. +# SERENITY_MEMBER_AUTOPROVISION=false # ── Server port (optional, default 3002) ────────────────────────────────────── # PORT=3001 \ No newline at end of file diff --git a/src/controllers/serenity.js b/src/controllers/serenity.js index 53f4648ee2..df6979715b 100644 --- a/src/controllers/serenity.js +++ b/src/controllers/serenity.js @@ -25,6 +25,7 @@ import { clearBrandWorkspaceCache, } from '../support/serenity/workspace-resolver.js'; import { mintSemrushImsToken } from '../support/serenity/semrush-ims-token.js'; +import { withMemberAutoProvision } from '../support/serenity/member-autoprovision.js'; import { handleListPrompts, handleCreatePrompts, @@ -81,7 +82,7 @@ import { resolveBrandUuid } from '../support/prompts-storage.js'; import { getBrandAliases, getBrandUrlSources, getBrandCompetitors, updateBrand, getBrandBaseSiteId, } from '../support/brands-storage.js'; -import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise } from '../support/utils.js'; +import { ErrorWithStatusCode, resolveSemrushImsToken as resolveImsTokenViaPromise, resolveCallerEmail } from '../support/utils.js'; import { hostnameFromUrlString } from '../support/url-utils.js'; import { ensureMarketSite, resolveSiteDomain, unlinkMarketSiteIfOrphaned } from '../support/serenity/site-linkage.js'; import { X_PROMISE_TOKEN_HEADER, PROMISE_TOKEN_REQUIRED_ERROR_CODE } from '../utils/constants.js'; @@ -201,6 +202,17 @@ function mapError(e, log) { err.status, ); } + if (err.status === 422) { + // An upstream unprocessable-entity refusal (e.g. member add rejected with + // "corporate account does not have enough user units" / limit_exceeded) is the + // caller's to act on, not an outage. Surface 422 with a specific token instead of + // flattening to a generic 502; the body (limit flag + emails) stays server-side only + // (logged above), consistent with the 401/403 redaction. + return createResponse( + { error: 'unprocessableEntity', message: 'Upstream rejected the request as unprocessable (quota or validation)' }, + 422, + ); + } return createResponse({ error: 'serenityUpstreamError', message: 'Upstream request failed', @@ -450,6 +462,28 @@ function SerenityController(context, log, env) { return createSerenityTransport({ env: ctx.env || env, imsToken }); } + // Auto-provision-on-401/403 flag (env/Vault boolean, default OFF). When a brand-scoped + // Semrush READ fails because the caller is not yet a member of the workspace, provision + // them on the fly (dedicated IMS token → add member, viewer) and retry the read once. + // Only meaningful when Semrush is integrated for the org (serenity active + a workspace + // resolves) — which `authorize` already guarantees before any read handler runs. + const memberAutoProvisionEnabled = (ctx) => (ctx?.env || env)?.SERENITY_MEMBER_AUTOPROVISION === 'true'; + + /** + * Wraps a Semrush read so a "caller not yet a member" 401/403 self-heals: on that + * upstream denial (and only when the flag is on and we have a workspace + caller email), + * the calling user is granted viewer access and the read is retried once. Best-effort — + * see member-autoprovision.js. `run` must be idempotent (it may execute twice). + */ + const readWithProvision = (ctx, auth, run) => withMemberAutoProvision({ + run, + env: ctx.env || env, + log, + enabled: memberAutoProvisionEnabled(ctx), + workspaceId: auth.workspaceId, + memberEmail: resolveCallerEmail(ctx), + }); + // Global dynamic-allocation kill-switch for this request (env/Vault boolean, default OFF). Read // per request off ctx.env, mirroring buildTransport's env resolution. When OFF the metered // handlers front through a no-op guard (byte-for-byte pre-PR behavior). @@ -511,15 +545,15 @@ function SerenityController(context, log, env) { return auth.error; } const transport = buildTransport(ctx, imsToken); - const result = auth.mode === 'subworkspace' - ? await handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) - : await handleListPrompts( + const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace' + ? handleListPromptsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) + : handleListPrompts( transport, ctx.dataAccess, auth.brandUuid, auth.workspaceId, parsedQuery(ctx), - ); + ))); return createResponse(result, 200); } catch (e) { return mapError(e, log); @@ -706,8 +740,8 @@ function SerenityController(context, log, env) { return auth.error; } const transport = buildTransport(ctx, imsToken); - const result = auth.mode === 'subworkspace' - ? await handleListMarketsSubworkspace( + const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace' + ? handleListMarketsSubworkspace( transport, /** @type {string} */ (auth.brandUuid), /** @type {string} */ (auth.workspaceId), @@ -716,12 +750,12 @@ function SerenityController(context, log, env) { ctx.dataAccess, log, ) - : await handleListMarkets( + : handleListMarkets( transport, ctx.dataAccess, auth.brandUuid, auth.workspaceId, - ); + ))); return createResponse(result, 200); } catch (e) { return mapError(e, log); @@ -743,8 +777,8 @@ function SerenityController(context, log, env) { // coerce '2840abc' → 2840 and silently resolve a different slice. const geoTargetId = /^\d+$/.test(String(pGeo || '')) ? Number(pGeo) : null; const languageCode = pLang ? String(pLang).toLowerCase() : null; - const result = auth.mode === 'subworkspace' - ? await handleGetMarketSubworkspace( + const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace' + ? handleGetMarketSubworkspace( buildTransport(ctx, imsToken), auth.brandUuid, auth.workspaceId, @@ -754,7 +788,7 @@ function SerenityController(context, log, env) { // Enrich the resolved slice with its siteId (LLMO-6405 Phase 2). ctx.dataAccess, ) - : await handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode); + : handleGetMarket(ctx.dataAccess, auth.brandUuid, geoTargetId, languageCode))); return createResponse(result, 200); } catch (e) { return mapError(e, log); @@ -997,16 +1031,16 @@ function SerenityController(context, log, env) { return auth.error; } const transport = buildTransport(ctx, imsToken); - const result = auth.mode === 'subworkspace' - ? await handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) - : await handleListTags( + const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace' + ? handleListTagsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) + : handleListTags( transport, ctx.dataAccess, auth.brandUuid, auth.workspaceId, parsedQuery(ctx), log, - ); + ))); return createResponse(result, 200); } catch (e) { return mapError(e, log); @@ -1106,15 +1140,15 @@ function SerenityController(context, log, env) { return auth.error; } const transport = buildTransport(ctx, imsToken); - const result = auth.mode === 'subworkspace' - ? await handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) - : await handleListModels( + const result = await readWithProvision(ctx, auth, () => (auth.mode === 'subworkspace' + ? handleListModelsSubworkspace(transport, auth.workspaceId, parsedQuery(ctx), log) + : handleListModels( transport, ctx.dataAccess, auth.brandUuid, auth.workspaceId, parsedQuery(ctx), - ); + ))); return createResponse(result, 200); } catch (e) { return mapError(e, log); diff --git a/src/support/serenity/member-autoprovision.js b/src/support/serenity/member-autoprovision.js new file mode 100644 index 0000000000..4144b25f63 --- /dev/null +++ b/src/support/serenity/member-autoprovision.js @@ -0,0 +1,101 @@ +/* + * Copyright 2026 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. + */ + +// @ts-check + +import { hasText } from '@adobe/spacecat-shared-utils'; +import { isSemrushTransportError, unwrapTransportCause } from './errors.js'; +import { mintSemrushImsToken } from './semrush-ims-token.js'; +import { createSerenityTransport } from './rest-transport.js'; + +const DEFAULT_ROLE = 'role/workspace/viewer'; + +/** + * True when the error is a Semrush auth denial (HTTP 401/403) on an outbound call. + * 403 is the fixable "authenticated but not yet a workspace member" case; 401 (an + * invalid/expired token) is included for completeness, but the single non-looping + * retry below makes an unfixable 401 harmless. + * + * @param {unknown} e + * @returns {boolean} + */ +export function isSemrushMembershipDenied(e) { + const err = unwrapTransportCause(e); + return isSemrushTransportError(err) && (err.status === 401 || err.status === 403); +} + +/** + * Runs a Semrush data read and, if it fails because the caller is not yet a member of + * the workspace (upstream 401/403), provisions the caller onto the workspace (viewer + * role) using the DEDICATED Semrush IMS technical-account token, then runs the read + * exactly once more. + * + * Guarantees: + * - SINGLE retry — never loops, even if the retry also 401/403s. + * - Best-effort provisioning: if the grant itself fails (e.g. 422 "no user units", or a + * token-mint config error), the ORIGINAL read error is surfaced — the grant error + * never masks why the read failed. + * - Transparent no-op (runs the read once, unwrapped) when disabled, when + * workspaceId/memberEmail is missing, or when the error is not a Semrush 401/403. + * + * The read keeps using the caller's own transport (passed in via `run`); provisioning + * uses a SEPARATE admin transport built from the minted service token. + * + * @template T + * @param {object} params + * @param {() => Promise} params.run - executes the Semrush read; must be safe to call twice. + * @param {object} params.env + * @param {{ info: Function, error: (m: string, meta?: object) => void }} params.log + * @param {boolean} params.enabled - the SERENITY_MEMBER_AUTOPROVISION flag. + * @param {string | null | undefined} params.workspaceId - the resolved brand workspace. + * @param {string | null | undefined} params.memberEmail - the calling user's email. + * @param {string} [params.role] - Semrush role to grant (default `role/workspace/viewer`). + * @returns {Promise} + */ +export async function withMemberAutoProvision({ + run, env, log, enabled, workspaceId, memberEmail, role = DEFAULT_ROLE, +}) { + if (!enabled) { + return run(); + } + try { + return await run(); + } catch (readError) { + // The truthiness checks also narrow the `string | null | undefined` params to + // `string` for TS (hasText is not a type guard — see this dir's CLAUDE.md). + if (!isSemrushMembershipDenied(readError) + || !workspaceId || !hasText(workspaceId) + || !memberEmail || !hasText(memberEmail)) { + throw readError; + } + try { + log.info('[serenity] auto-provisioning workspace member after upstream 401/403', { + workspaceId, + }); + const imsToken = await mintSemrushImsToken(env, log); + const adminTransport = createSerenityTransport({ env, imsToken }); + await adminTransport.addWorkspaceMembers(workspaceId, [memberEmail], role); + } catch (provisionError) { + // Provisioning is best-effort: surface the ORIGINAL read error (the 401/403), not + // the grant error, so a "no seats" (422) or mint-config failure never masks the + // reason the read failed. The grant error is logged for diagnosis only. + log.error('[serenity] member auto-provision failed; surfacing original read error', { + workspaceId, + provisionError: provisionError?.message, + }); + throw readError; + } + // Provisioned — retry the read exactly once. If it still fails, that error propagates + // (no further provisioning attempt). + return run(); + } +} diff --git a/src/support/utils.js b/src/support/utils.js index c25cfd6389..54ea4a0893 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -874,6 +874,22 @@ export function resolveCallerImsUserId(context) { return hasText(raw) ? String(raw) : null; } +/** + * Resolves the caller's human email address (NOT the opaque IMS user id) from the + * authenticated profile — the address to add as a Semrush workspace member. Prefers + * `email`, then `preferred_username` (IMS typically sets this to the email), then + * `trial_email`. Returns null when none resolve, in which case callers must skip any + * member-provisioning rather than guess. + * + * @param {object} context - The request context. + * @returns {string|null} The caller's email, or null when unresolvable. + */ +export function resolveCallerEmail(context) { + const profile = context?.attributes?.authInfo?.getProfile?.(); + const raw = profile?.email ?? profile?.preferred_username ?? profile?.trial_email; + return hasText(raw) ? String(raw) : null; +} + /** * Resolves the IMS access token to forward to the Semrush gateway for a request. * diff --git a/test/controllers/serenity.test.js b/test/controllers/serenity.test.js index 75d7a5e29c..e56e4cfbcc 100644 --- a/test/controllers/serenity.test.js +++ b/test/controllers/serenity.test.js @@ -172,6 +172,7 @@ describe('SerenityController', () => { let isSerenityActiveStub; let createTransportStub; let mintSemrushImsTokenStub; + let withMemberAutoProvisionStub; let resolveBrandUuidStub; let getBrandAliasesStub; let getBrandUrlSourcesStub; @@ -206,6 +207,10 @@ describe('SerenityController', () => { clearBrandWorkspaceCacheStub = sinon.stub(); createTransportStub = sinon.stub().returns({ name: 'transport' }); mintSemrushImsTokenStub = sinon.stub().resolves('semrush-ims-tech-token'); + // Default: transparent pass-through (just run the read), so existing read tests are + // unaffected. The auto-provision behavior itself is unit-tested in + // test/support/serenity/member-autoprovision.test.js; here we assert the wiring. + withMemberAutoProvisionStub = sinon.stub().callsFake(({ run }) => run()); resolveBrandUuidStub = sinon.stub().resolves(BRAND); getBrandAliasesStub = sinon.stub().resolves([]); getBrandUrlSourcesStub = sinon.stub() @@ -250,6 +255,9 @@ describe('SerenityController', () => { '../../src/support/serenity/semrush-ims-token.js': { mintSemrushImsToken: mintSemrushImsTokenStub, }, + '../../src/support/serenity/member-autoprovision.js': { + withMemberAutoProvision: withMemberAutoProvisionStub, + }, '../../src/support/serenity/handlers/prompts.js': { handleListPrompts: handlers.handleListPrompts, handleCreatePrompts: handlers.handleCreatePrompts, @@ -1791,6 +1799,61 @@ describe('SerenityController', () => { }); }); + describe('read auto-provision wiring (withMemberAutoProvision)', () => { + it('wraps listPrompts with provisioning DISABLED by default, passing the workspace', async () => { + handlers.handleListPrompts.resolves({ + items: [], total: 0, page: 1, limit: 50, + }); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const ctx = fakeContext(); + ctx.request = { url: 'https://x/v2/orgs/x/brands/y/serenity/prompts' }; + + await controller.listPrompts(ctx); + + expect(withMemberAutoProvisionStub).to.have.been.calledOnce; + const args = withMemberAutoProvisionStub.firstCall.args[0]; + expect(args.enabled).to.equal(false); + expect(args.workspaceId).to.equal(WORKSPACE); + // Pass-through still produced the handler's result (200). + expect(handlers.handleListPrompts).to.have.been.calledOnce; + }); + + it('enables provisioning when SERENITY_MEMBER_AUTOPROVISION=true and passes the caller email', async () => { + handlers.handleListMarkets.resolves([]); + const controller = SerenityController( + { env: { SERENITY_MEMBER_AUTOPROVISION: 'true' } }, + fakeLog(), + { SERENITY_MEMBER_AUTOPROVISION: 'true' }, + ); + const ctx = fakeContext({ env: { SERENITY_MEMBER_AUTOPROVISION: 'true' } }); + ctx.attributes.authInfo.getProfile = () => ({ email: 'caller@adobe.com' }); + + await controller.listMarkets(ctx); + + const args = withMemberAutoProvisionStub.firstCall.args[0]; + expect(args.enabled).to.equal(true); + expect(args.memberEmail).to.equal('caller@adobe.com'); + expect(args.workspaceId).to.equal(WORKSPACE); + }); + + it('wraps every brand-scoped read handler (prompts, markets, getMarket, tags, models)', async () => { + handlers.handleListPrompts.resolves({ items: [] }); + handlers.handleListMarkets.resolves([]); + handlers.handleGetMarket.resolves({}); + handlers.handleListTags.resolves([]); + handlers.handleListModels.resolves([]); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + + await controller.listPrompts(fakeContext({ params: {} })); + await controller.listMarkets(fakeContext({ params: {} })); + await controller.getMarket(fakeContext({ params: { geoTargetId: '2840', languageCode: 'en' } })); + await controller.listTags(fakeContext({ params: {} })); + await controller.listModels(fakeContext({ params: {} })); + + expect(withMemberAutoProvisionStub.callCount).to.equal(5); + }); + }); + describe('addMembers (RBAC workspace member grant — minted Semrush IMS token)', () => { let addWorkspaceMembersStub; @@ -1910,6 +1973,24 @@ describe('SerenityController', () => { })); expect(response.status).to.equal(403); }); + + it('surfaces an upstream 422 (no user units) as 422, not a generic 502', async () => { + addWorkspaceMembersStub.rejects( + new MockTransportError(422, 'corporate account does not have enough user units', { + limit_exceeded: true, + emails: ['a@adobe.com'], + }), + ); + const controller = SerenityController({ env: {} }, fakeLog(), {}); + const response = await controller.addMembers(fakeContext({ + data: { members: ['a@adobe.com'] }, + })); + expect(response.status).to.equal(422); + const body = await readBody(response); + expect(body.error).to.equal('unprocessableEntity'); + // The limit flag + emails must NOT leak to the client. + expect(JSON.stringify(body)).to.not.include('limit_exceeded'); + }); }); describe('activate / deactivate', () => { diff --git a/test/support/serenity/member-autoprovision.test.js b/test/support/serenity/member-autoprovision.test.js new file mode 100644 index 0000000000..acb332945e --- /dev/null +++ b/test/support/serenity/member-autoprovision.test.js @@ -0,0 +1,148 @@ +/* + * Copyright 2026 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 { use, expect } from 'chai'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import { SerenityTransportError } from '../../../src/support/serenity/rest-transport.js'; + +use(sinonChai); + +function fakeLog() { + return { info: sinon.stub(), error: sinon.stub() }; +} + +// Awaits a promise expected to reject and returns the thrown error (or null). +async function caught(promise) { + try { + await promise; + return null; + } catch (e) { + return e; + } +} + +describe('withMemberAutoProvision', () => { + let mintStub; + let addWorkspaceMembersStub; + let createTransportStub; + let mod; + + beforeEach(async () => { + mintStub = sinon.stub().resolves('svc-token'); + addWorkspaceMembersStub = sinon.stub().resolves({ consumedUnits: -1 }); + createTransportStub = sinon.stub().returns({ addWorkspaceMembers: addWorkspaceMembersStub }); + mod = await esmock('../../../src/support/serenity/member-autoprovision.js', { + '../../../src/support/serenity/semrush-ims-token.js': { mintSemrushImsToken: mintStub }, + '../../../src/support/serenity/rest-transport.js': { + createSerenityTransport: createTransportStub, + }, + }); + }); + + afterEach(() => sinon.restore()); + + // Fresh args (incl. a fresh log) per call, so no fake is shared across tests. + const mkArgs = (overrides = {}) => ({ + env: { SOME: 'env' }, + log: fakeLog(), + enabled: true, + workspaceId: 'ws-1', + memberEmail: 'user@adobe.com', + ...overrides, + }); + + it('returns the read result and does not provision when the read succeeds', async () => { + const run = sinon.stub().resolves('data'); + const out = await mod.withMemberAutoProvision({ ...mkArgs(), run }); + expect(out).to.equal('data'); + expect(run).to.have.been.calledOnce; + expect(mintStub).to.not.have.been.called; + }); + + it('provisions the caller (viewer) and retries once on a 403, returning the retry result', async () => { + const run = sinon.stub(); + run.onFirstCall().rejects(new SerenityTransportError(403, 'not a member')); + run.onSecondCall().resolves('data-after-provision'); + + const out = await mod.withMemberAutoProvision({ ...mkArgs(), run }); + + expect(out).to.equal('data-after-provision'); + expect(run).to.have.been.calledTwice; + expect(mintStub).to.have.been.calledOnce; + expect(addWorkspaceMembersStub).to.have.been.calledOnceWithExactly( + 'ws-1', + ['user@adobe.com'], + 'role/workspace/viewer', + ); + }); + + it('also triggers on a 401', async () => { + const run = sinon.stub(); + run.onFirstCall().rejects(new SerenityTransportError(401, 'unauth')); + run.onSecondCall().resolves('ok'); + const out = await mod.withMemberAutoProvision({ ...mkArgs(), run }); + expect(out).to.equal('ok'); + expect(addWorkspaceMembersStub).to.have.been.calledOnce; + }); + + it('does not provision (rethrows) when disabled', async () => { + const run = sinon.stub().rejects(new SerenityTransportError(403, 'x')); + const err = await caught(mod.withMemberAutoProvision({ ...mkArgs({ enabled: false }), run })); + expect(err).to.be.instanceOf(SerenityTransportError); + expect(run).to.have.been.calledOnce; + expect(mintStub).to.not.have.been.called; + }); + + it('rethrows a non-401/403 upstream error without provisioning', async () => { + const run = sinon.stub().rejects(new SerenityTransportError(500, 'boom')); + const err = await caught(mod.withMemberAutoProvision({ ...mkArgs(), run })); + expect(err?.status).to.equal(500); + expect(mintStub).to.not.have.been.called; + expect(run).to.have.been.calledOnce; + }); + + it('skips provisioning when memberEmail is missing', async () => { + const run = sinon.stub().rejects(new SerenityTransportError(403, 'x')); + const args = { ...mkArgs({ memberEmail: null }), run }; + const err = await caught(mod.withMemberAutoProvision(args)); + expect(err?.status).to.equal(403); + expect(mintStub).to.not.have.been.called; + }); + + it('surfaces the ORIGINAL read error when provisioning fails (e.g. 422 no seats)', async () => { + const run = sinon.stub().rejects(new SerenityTransportError(403, 'not a member')); + addWorkspaceMembersStub.rejects(new SerenityTransportError(422, 'no user units')); + const err = await caught(mod.withMemberAutoProvision({ ...mkArgs(), run })); + expect(err?.status).to.equal(403); // original read error, not the 422 + expect(run).to.have.been.calledOnce; // no retry after a failed provision + }); + + it('does NOT loop: a retry that still 403s propagates after exactly one retry', async () => { + const run = sinon.stub().rejects(new SerenityTransportError(403, 'still not a member')); + const err = await caught(mod.withMemberAutoProvision({ ...mkArgs(), run })); + expect(err?.status).to.equal(403); + expect(run).to.have.been.calledTwice; // initial + one retry, then give up + expect(mintStub).to.have.been.calledOnce; + }); + + describe('isSemrushMembershipDenied', () => { + it('is true for 401/403 Semrush transport errors, false otherwise', () => { + expect(mod.isSemrushMembershipDenied(new SerenityTransportError(403, 'x'))).to.equal(true); + expect(mod.isSemrushMembershipDenied(new SerenityTransportError(401, 'x'))).to.equal(true); + expect(mod.isSemrushMembershipDenied(new SerenityTransportError(404, 'x'))).to.equal(false); + expect(mod.isSemrushMembershipDenied(new SerenityTransportError(422, 'x'))).to.equal(false); + expect(mod.isSemrushMembershipDenied(new Error('plain'))).to.equal(false); + }); + }); +});