Skip to content

feat(api-key): route-scoped IMS handler and middleware-driven controller auth#2344

Open
ravverma wants to merge 9 commits into
mainfrom
feat/api-key-ims-handler-migration
Open

feat(api-key): route-scoped IMS handler and middleware-driven controller auth#2344
ravverma wants to merge 9 commits into
mainfrom
feat/api-key-ims-handler-migration

Conversation

@ravverma
Copy link
Copy Markdown
Contributor

@ravverma ravverma commented May 6, 2026

Implements the IMS-to-JWT migration design for the api-key controller from mysticat-architecture/platform/decisions/ims-to-jwt-api-key-controller-migration.md (Option A, conservative variant: ApiKeyImsHandler added alongside the global AdobeImsHandler so Auto-Fix and other still-IMS routes continue to work until ASO-607 lands).

What changes

  • New src/support/api-key-ims-handler.js: subclass of AdobeImsHandler whose checkAuth returns null for any path other than /tools/api-keys*, delegating to super only for the api-key endpoints. The handler name stays ims, so authInfo.getType() and downstream code see no difference for IaaS callers.

  • src/index.js AUTH_HANDLERS chain now reads: [SkipAuthHandler, GitHubWebhookHmacHandler, JwtHandler, ApiKeyImsHandler, AdobeImsHandler, ScopedApiKeyHandler, LegacyApiKeyHandler] ApiKeyImsHandler precedes AdobeImsHandler so the scoped path matches first for /tools/api-keys; for any other route it returns null and the existing global handler still handles IMS auth. Once Auto-Fix migrates, removing AdobeImsHandler is a one-line change.

  • src/controllers/api-key.js no longer calls imsClient.getImsUserProfile or extracts the bearer token itself. Per the design's six refactor steps:

    1. Removed validateImsOrgId() and getImsUserToken() private functions.
    2. Org membership now checked via authInfo.hasOrganization(imsOrgId).
    3. Removed imsUserProfile.email usage.
    4. imsUserId taken from authInfo.getProfile().email (consistent for both IMS and JWT auth - documented in the resolveCaller() JSDoc).
    5. imsClient dependency removed from the controller.
    6. Tests updated to mock attributes.authInfo instead of imsClient.

    All three endpoints (POST/DELETE/GET /tools/api-keys[/]) now flow
    through a shared resolveCaller() helper that surfaces a structured
    ErrorWithStatusCode for missing header / missing authInfo / missing
    user_id / org-mismatch.

Tests

  • test/controllers/api-key.test.js: rewritten to mock authInfo directly. Covers all six branches (missing imsOrgId, missing authInfo, missing user_id, org-mismatch, all happy paths, configuration parse failure).
  • test/support/api-key-ims-handler.test.js: new file. esmock stubs the parent class and asserts the path scoping in isolation - verifies super is called for /tools/api-keys[/], and is NOT called for /sites, /tools, /tools/other-tool, missing pathInfo, or non-string suffix.
  • test/auth-handlers-order.test.js: extended with an executable contract asserting ApiKeyImsHandler precedes AdobeImsHandler in AUTH_HANDLERS. Refactored to share the parser between assertions.

Suite: 9004 passing, 100% statement / branch / line coverage.

Please ensure your pull request adheres to the following guidelines:

  • make sure to link the related issues in this description. Or if there's no issue created, make sure you
    describe here the problem you're solving.
  • when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes

If the PR is changing the API specification:

  • make sure you add a "Not implemented yet" note the endpoint description, if the implementation is not ready
    yet. Ideally, return a 501 status code with a message explaining the feature is not implemented yet.
  • make sure you add at least one example of the request and response.

If the PR is changing the API implementation or an entity exposed through the API:

  • make sure you update the API specification and the examples to reflect the changes.

If the PR is introducing a new audit type:

  • make sure you update the API specification with the type, schema of the audit result and an example

Related Issues

Thanks for contributing!

…ler auth

Implements the IMS-to-JWT migration design for the api-key controller from
mysticat-architecture/platform/decisions/ims-to-jwt-api-key-controller-migration.md
(Option A, conservative variant: ApiKeyImsHandler added alongside the global
AdobeImsHandler so Auto-Fix and other still-IMS routes continue to work until
ASO-607 lands).

What changes

- New `src/support/api-key-ims-handler.js`: subclass of AdobeImsHandler whose
  `checkAuth` returns null for any path other than `/tools/api-keys*`,
  delegating to super only for the api-key endpoints. The handler name stays
  `ims`, so authInfo.getType() and downstream code see no difference for
  IaaS callers.

- `src/index.js` AUTH_HANDLERS chain now reads:
    [SkipAuthHandler, GitHubWebhookHmacHandler, JwtHandler,
     ApiKeyImsHandler, AdobeImsHandler, ScopedApiKeyHandler, LegacyApiKeyHandler]
  ApiKeyImsHandler precedes AdobeImsHandler so the scoped path matches first
  for /tools/api-keys; for any other route it returns null and the existing
  global handler still handles IMS auth. Once Auto-Fix migrates, removing
  AdobeImsHandler is a one-line change.

- `src/controllers/api-key.js` no longer calls `imsClient.getImsUserProfile`
  or extracts the bearer token itself. Per the design's six refactor steps:
    1. Removed validateImsOrgId() and getImsUserToken() private functions.
    2. Org membership now checked via `authInfo.hasOrganization(imsOrgId)`.
    3. Removed `imsUserProfile.email` usage.
    4. imsUserId taken from `authInfo.getProfile().email` (consistent for
       both IMS and JWT auth - documented in the resolveCaller() JSDoc).
    5. `imsClient` dependency removed from the controller.
    6. Tests updated to mock `attributes.authInfo` instead of `imsClient`.

  All three endpoints (POST/DELETE/GET /tools/api-keys[/<id>]) now flow
  through a shared `resolveCaller()` helper that surfaces a structured
  ErrorWithStatusCode for missing header / missing authInfo / missing
  user_id / org-mismatch.

Tests
- `test/controllers/api-key.test.js`: rewritten to mock authInfo directly.
  Covers all six branches (missing imsOrgId, missing authInfo, missing
  user_id, org-mismatch, all happy paths, configuration parse failure).
- `test/support/api-key-ims-handler.test.js`: new file. esmock stubs the
  parent class and asserts the path scoping in isolation - verifies super
  is called for /tools/api-keys[/<id>], and is NOT called for /sites,
  /tools, /tools/other-tool, missing pathInfo, or non-string suffix.
- `test/auth-handlers-order.test.js`: extended with an executable contract
  asserting `ApiKeyImsHandler` precedes `AdobeImsHandler` in AUTH_HANDLERS.
  Refactored to share the parser between assertions.

Suite: 9004 passing, 100% statement / branch / line coverage.
@ravverma ravverma requested a review from solaris007 May 6, 2026 10:17
Comment thread src/index.js
GitHubWebhookHmacHandler,
JwtHandler,
ApiKeyImsHandler,
AdobeImsHandler,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping AdobeImsHandler in the chain to support auto_fix flow before its migration

@codecov
Copy link
Copy Markdown

codecov Bot commented May 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Member

@solaris007 solaris007 left a comment

Choose a reason for hiding this comment

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

Hey @ravverma,

Strengths

  • resolveCaller() is a real improvement (api-key.js:95-130). Consolidating three scattered functions (token extraction, IMS round-trip, profile-derived ID) into a single operation with a well-defined error contract is cleaner and eliminates a per-request IMS call that the middleware already handles.
  • The subclass approach is minimal and deletable (api-key-ims-handler.js:1-52). Extending AdobeImsHandler with only a path guard, inheriting the 'ims' handler name so authInfo.getType() is unchanged for downstream code, and keeping the file to 52 lines makes this easy to review and easy to rip out once ASO-607 lands.
  • Auth-handler ordering is mechanically enforced (auth-handlers-order.test.js:57-76). The executable contract test catches accidental import reorders before they become security bugs. The shared parseAuthHandlersOrder() helper refactor is a nice touch.
  • Strong negative-path test coverage. Org-membership 401 on delete (api-key.test.js:236-243), org-membership 401 on get (api-key.test.js:262-268), and the handler's null-guard surface (api-key-ims-handler.test.js:72-101) all assert the right behavior with the right side-effect expectations (no DB calls when auth fails).
  • Controller is now a pure authInfo consumer. Removing imsClient from the controller harmonizes this code with the rest of the codebase that trusts the auth middleware, and eliminates a class of bugs where controllers and middleware can disagree about identity.

Issues

Important (Should Fix)

1. Username-prefix behavior change for IMS callers is undocumented and untested.
src/controllers/api-key.js:172 - The old code derived the API key's username prefix from imsUserProfile.email (the real email from an IMS profile fetch). The new code derives it from authInfo.getProfile().email, which for IMS callers AdobeImsHandler.transformProfile sets to payload.user_id (a GUID-style ID like ABC123@AdobeID), not the real email. Existing IMS keys were named firstname-<uuid>; new IMS keys will be named <imsGuidPrefix>-<uuid>. This is user-visible for IaaS consumers. The bare-UUID test at api-key.test.js:187 asserts only STATUS_CREATED, not the actual key format, so neither the old nor new behavior is pinned.
Fix: (a) Add a test with an IMS-style profile ({ email: 'ABC123@AdobeID' }) and assert the key matches ^ABC123-<uuid>$. (b) Strengthen the @AdobeID test to assert the key matches bare UUID format. (c) Acknowledge the change in the PR description or release notes so IaaS UI consumers are not surprised.

2. Comment overstates the bare-UUID fallback surface.
src/controllers/api-key.js:169 - The comment says "falling back to a bare UUID (e.g. for IDs that lead with @ or have no @ at all)". But 'noatsign'.split('@') returns ['noatsign'] (truthy), so the key would be noatsign-<uuid>, not a bare UUID. Only a leading @ produces the fallback.
Fix: Drop "or have no @ at all" from the comment.

3. No observability signal for the IMS migration end-state.
src/support/api-key-ims-handler.js:50 - The whole architectural premise is that this handler is transitional and deletable once IaaS callers move to JWT. But nothing distinguishes "IaaS request authenticated via IMS" from "IaaS request authenticated via JWT" in logs or metrics. Without that signal, the deletion plan is fragile - someone has to guess when the last IMS caller has migrated.
Fix: After super.checkAuth() returns successfully, emit an info-level log (e.g. this.log.info('api-key request authenticated via scoped IMS handler - JWT migration pending')). Cheap to add, cheap to delete with the rest of the file.

Minor (Nice to Have)

1. Path-prefix check not anchored at word boundary.
api-key-ims-handler.js:46 - startsWith('/tools/api-keys') would also match a hypothetical /tools/api-keys-batch. No such routes exist today and the test documents the behavior, but the sibling GitHubWebhookHmacHandler uses a boundary-anchored regex. For consistency: suffix === API_KEY_PATH_PREFIX || suffix.startsWith(API_KEY_PATH_PREFIX + '/').

2. !authInfo error message is misleading.
api-key.js:131 - The guard protects against the middleware not running, but the message says "Missing Authorization header". The header was present (the middleware consumed it). Consider "auth middleware did not populate authInfo" or similar.

3. validateRequestData now runs before auth check.
api-key.js:147-148 - Previously token extraction ran first. Now body validation runs before resolveCaller(). Both orders are defensible, but the change is silent. Consider reordering to auth-first (fail fast on unauthenticated requests) or noting the intentional change.

Recommendations

  • Track deletion of ApiKeyImsHandler as a sub-task of ASO-607 or add a // TODO(ASO-607) comment in the source so the removal isn't dependent on someone remembering the design doc.
  • Consider exporting the makeAuthInfo test helper from a shared test-helpers file if other controller tests need to stub authInfo.

Assessment

Ready to merge? With fixes.

The architecture is sound - a minimal, deletable, well-justified piece of transitional code that faithfully implements the design doc's Option A. The controller refactor is a real improvement. The three Important items are narrow: a silent behavior change in the API key display name for IMS callers that should be documented and tested, an inaccurate comment, and a missing log line for migration observability. All fixable with small commits.

// (e.g. for IDs that lead with `@` or have no `@` at all). The username
// is purely cosmetic - it lets users recognize their own keys at a glance.
// imsUserId is guaranteed non-empty by resolveCaller().
const [username] = imsUserId.split('@');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Important: Silent behavior change for IMS callers. The old code derived the username prefix from imsUserProfile.email (the real email). The new code uses authInfo.getProfile().email, which for IMS callers is payload.user_id (a GUID-style ID like ABC123@AdobeID). New IMS keys will have a different-looking prefix than existing ones.

Consider adding a test with an IMS-style profile ({ email: 'ABC123@AdobeID' }) to pin the new behavior, and noting the change in the PR description.

// `@` (e.g. an IMS-issued ID without a local-part), the username prefix
// is empty and we fall back to a bare UUID.
context.attributes.authInfo = makeAuthInfo({ profileEmail: '@AdobeID' });
apiKeyController = ApiKeyController(context);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test name promises "fall back to a bare UUID" but only asserts STATUS_CREATED. The UUID-only branch is not verified. Consider: const json = await response.json(); expect(json.apiKey).to.match(/^[0-9a-f]{8}-/); to confirm no username prefix is present.

Comment thread src/controllers/api-key.js Outdated
// Create the API key
// Username prefix is best-effort: we use the local-part of the caller's
// email/user_id when an `@` is present, falling back to a bare UUID
// (e.g. for IDs that lead with `@` or have no `@` at all). The username
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: "have no @ at all" is inaccurate here. 'noatsign'.split('@') returns ['noatsign'] (truthy), so the key would be noatsign-<uuid>, not a bare UUID. Only a leading @ triggers the fallback. Drop the "or have no @ at all" clause.

Comment thread src/support/api-key-ims-handler.js Outdated
// Out-of-scope route - skip cleanly so the auth chain advances.
return null;
}
return super.checkAuth(request, context);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider adding an info-level log here when the scoped IMS path succeeds, e.g.:

const result = await super.checkAuth(request, context);
if (result) {
  this.log.info('api-key request authenticated via scoped IMS handler - JWT migration pending');
}
return result;

This gives operators a queryable signal for "is anyone still hitting the IMS path?" - without it, the plan to delete this handler requires manual investigation.

Important
- Pin IMS-style username prefix behavior with two new tests:
  - JWT caller: profile.email is the real email -> prefix is local-part.
  - IMS caller: profile.email is payload.user_id (e.g. ABC123@AdobeID) ->
    prefix is the GUID portion. Documents the silent behavior change from
    the previous IMS-round-trip controller.
  Strengthened the bare-UUID test to assert the actual UUID format
  (no leading prefix), not just the status code.
- Drop the inaccurate "or have no `@` at all" clause from the username
  comment. `'noatsign'.split('@')` returns `['noatsign']` (truthy), so
  only a leading `@` triggers the bare-UUID fallback.
- Add an info-level log on successful scoped IMS auth so operators have a
  queryable signal for "is anyone still hitting this path?". Once the log
  stops firing in production, the handler can be safely deleted (TODO is
  documented in the file header tied to ASO-607).

Minor
- Anchor the path-prefix check at a `/` boundary
  (suffix === '/tools/api-keys' || suffix.startsWith('/tools/api-keys/')).
  Prevents a hypothetical /tools/api-keys-batch from accidentally falling
  under this scope. New test covers the look-alike case explicitly.
- Clarify the "no authInfo" error message: it now reads
  "Unauthorized: auth middleware did not populate authInfo" instead of
  "Missing Authorization header" (the header was already consumed
  upstream; surfacing it as a request-side error was misleading).
- Reorder createApiKey to authenticate first, then validate body. Fails
  fast on unauthenticated requests and avoids leaking the expected
  request shape to anonymous callers.
- TODO(ASO-607) added in the handler file header so the deletion plan is
  visible at the source, not only in the design doc.

Suite: 9008 passing, 100% statement / branch / line coverage.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

This PR will trigger a minor release when merged.

@ravverma
Copy link
Copy Markdown
Contributor Author

ravverma commented May 6, 2026

Thanks @solaris007 - addressed in 3607d887.

Important

1. Username-prefix behavior change for IMS callers - Pinned with two new tests in test/controllers/api-key.test.js:

  • JWT caller (real email on profile.email) -> key matches /^test-[uuid]$/.
  • IMS caller (profile.email = 'ABC123@AdobeID' from transformProfile) -> key matches /^ABC123-[uuid]$/.
    The bare-UUID test was strengthened to assert the actual UUID format (no leading prefix). The behavior change is also called out explicitly in the username-derivation comment in api-key.js so future readers see it at the source. Will add a release-note line in the PR description.

2. Comment overstates fallback surface - Dropped "or have no @ at all". Rewritten comment now reflects the actual behavior: only a leading @ triggers the bare-UUID fallback.

3. No observability signal - Added an info-level log in ApiKeyImsHandler.checkAuth after a successful super.checkAuth():

api-key request authenticated via scoped IMS handler - JWT migration pending

Plus a corresponding TODO(ASO-607) in the file header that names the log as the operational signal for the deletion plan. Two new tests:

  • emits an info log when the scoped IMS auth succeeds
  • does NOT emit the success log when super.checkAuth returns null (auth failed)

Minor

1. Path-prefix not anchored at word boundary - Now uses an exact-or-/-descendant check via isApiKeyRoute():

suffix === API_KEY_PATH_PREFIX || suffix.startsWith(`${API_KEY_PATH_PREFIX}/`)

The pre-existing test that used to document the loose-prefix behavior has been replaced by returns null for a /tools/api-keys-batch look-alike (boundary anchor).

2. !authInfo error message misleading - Changed from Missing Authorization header to Unauthorized: auth middleware did not populate authInfo. Existing test updated to assert the new message.

3. validateRequestData order - Reordered to authenticate first via resolveCaller(imsOrgId), then validate the body. Comment in the source explains the reason (fail fast on unauthenticated requests; do not leak the expected request shape).

Recommendations

  • TODO(ASO-607) added in src/support/api-key-ims-handler.js file header.
  • The makeAuthInfo helper in api-key.test.js is currently used only by that one suite; happy to lift it to a shared file if/when a second consumer arrives.

Test status

  • 9008 passing locally, 100% statement/branch/line coverage.
  • Lint clean.

Ready for re-review.

@ravverma ravverma requested a review from solaris007 May 6, 2026 17:17
Copy link
Copy Markdown
Member

@solaris007 solaris007 left a comment

Choose a reason for hiding this comment

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

Hey @ravverma,

Thanks for the thorough fix commit (3607d887) - all six prior findings are verifiably addressed. The remaining items below are independent concerns, mostly defensive/architectural, surfaced by a deeper second pass focused on the security-sensitive auth path.

Strengths

  • All six prior review findings verified addressed in 3607d887:
    • Username-prefix behavior change pinned with three regression tests at test/controllers/api-key.test.js:182-215 (JWT real-email, IMS user_id, bare-UUID @-prefix fallback)
    • Bare-UUID comment now correctly says "only when ID leads with @" (src/controllers/api-key.js:178-185)
    • Migration observability log (src/support/api-key-ims-handler.js:75) plus removal-trigger TODO at lines 50-54 / 66-69
    • Path-prefix anchored via isApiKeyRoute() helper with explicit /tools/api-keys-batch boundary test
    • !authInfo error message now correctly points at the middleware boundary (src/controllers/api-key.js:122-124)
    • resolveCaller runs before validateRequestData in createApiKey (lines 153-154)
  • Token validation hops are correctly preserved: super.checkAuth in ApiKeyImsHandler runs the full upstream AdobeImsHandler validation (signature, expiry, IMS profile and org round-trip). No token-validation bypass introduced.
  • Per-request IMS revocation propagation is preserved - the controller-level IMS round-trip removed by this PR was duplicative with the handler's getImsUserProfile/getImsUserOrganizations calls.
  • The executable AUTH_HANDLERS order contract in test/auth-handlers-order.test.js:65-83 makes the architectural choice non-regressable.
  • Negative-space coverage is excellent: tests assert expect(...).to.not.have.been.called on data-access stubs in the org-membership 401 paths (test/controllers/api-key.test.js:266-272, 293-298), proving no DB access on auth failure.
  • crypto.randomUUID() (~122 bits effective entropy) plus hashWithSHA256 is the correct primitive choice for high-entropy random API keys; salt-less SHA-256 is standard practice for this material.
  • deleteApiKey returns 404 for both "not found" and "not yours" branches, preventing existence enumeration.

Issues

Important (Should Fix)

1. Cross-protocol identity divergence creates orphaned, unmanageable API keys. src/controllers/api-key.js:118-126, :241-244

The same human gets different imsUserId values depending on auth path:

  • IMS via ApiKeyImsHandler: profile.email = payload.user_id (e.g. ABC123@AdobeID, set by AdobeImsHandler.transformProfile)
  • JWT via JwtHandler: profile.email = payload.email (real email)

The controller persists whichever value profile.email has at creation time. After this PR, IaaS-only orgs use the IMS path and store ABC123@AdobeID. Once an org migrates to JWT (the ASO-607 trajectory), the same human authenticating via JWT presents imsUserId = real@adobe.com, which does not match the persisted ownership. Result:

  • getApiKeys returns empty list under the new identity - existing keys appear "lost"
  • deleteApiKey returns 404 - users cannot revoke their own keys
  • The orphaned keys remain VALID (ScopedApiKeyHandler still honors them) - they grant access but the legitimate owner cannot rotate or revoke them

The controller comment at lines 118-126 documents the asymmetry as "intentional and documented", but the operational consequence (cannot revoke own key after auth-mode change, key stays valid until 6mo expiry) is a security lifecycle gap, not just a UX note. For IaaS-only orgs that never migrate, the divergence is the steady state, not a transitional artifact - which makes imsUserId an actively misleading field name.

Fix options (in ascending invasiveness):

  • Document the steady-state behavior explicitly and accept it via an ADR addendum, ensuring the deletion plan in ASO-607 includes a remap migration for keys created under the IMS identity
  • Widen the ownership check to try both identity forms when the JWT path can produce a stable IMS user_id alongside the real email
  • Query both shapes in allByImsOrgIdAndImsUserId during the migration window and return the union

This is the architecture-level concern that warrants alignment before more controllers migrate and bake in the same assumption.

2. Strengthen the authInfo contract at resolveCaller. src/controllers/api-key.js:117, :130

Two related defensive gaps:

(a) The controller's safety net against non-{IMS, JWT} handlers populating authInfo is implicit - it depends on alternate handler profile shapes lacking an .email field. Verified today: LegacyApiKeyHandler returns { user_id: 'admin' | 'legacy-user' }, ScopedApiKeyHandler returns the ApiKey entity. Neither has email, so profile?.email falls through to 401. But this is absence-of-fields safety, not a positive control. A future spacecat-shared schema change adding .email to either shape would silently turn a 401 into successful auth across handler types. Add a positive whitelist:

const allowed = ['ims', 'jwt'];
if (!allowed.includes(authInfo.getType?.())) {
  throw new ErrorWithStatusCode('Unauthorized', STATUS_UNAUTHORIZED);
}

(b) !authInfo.hasOrganization?.(imsOrgId) (line 130) optional-chains a contract method. If hasOrganization is missing, the negation evaluates truthy and emits the "Unable to find a reference to the Organization provided" 401 - a misleading client-error message for what is actually a server-side authInfo-shape bug. Either gate explicitly:

if (typeof authInfo.hasOrganization !== 'function') {
  throw new ErrorWithStatusCode('Server error: authInfo missing hasOrganization contract', STATUS_INTERNAL_SERVER_ERROR);
}
if (!authInfo.hasOrganization(imsOrgId)) { ... }

or call unconditionally and let the resulting TypeError propagate as a 500. Both make the contract on authInfo explicit and surface handler-shape regressions as 5xx (server bug) rather than 401 (looks like client error).

3. Test stub diverges from production AbstractHandler.log contract; assertion is misleading. test/support/api-key-ims-handler.test.js:38-40, :96-99

The stub installs this.log = (message, level) => log?.[level || 'info']?.(message). Production AbstractHandler.log (verified in spacecat-shared-http-utils) is this.logger[level](\[${this.name}] ${message}`)- it adds an[ims]prefix. So in production the actual logger receives[ims] api-key request authenticated via scoped IMS handler - JWT migration pending, but the test asserts calledOnceWithExactly('api-key request authenticated...')` (no prefix).

The test passes today, but it pins the wrong shape. A future change to how the handler exposes its logger (e.g. someone overriding name or refactoring AbstractHandler) would silently keep the test green while the production migration signal output drifts. Either align the stub to call log[level](`[${this.name}] ${message}`) and update the assertion to match, or relax to a less brittle matcher (calledWithMatch(/api-key request authenticated/)). Same fragility applies to making the stub a thin extension of the real AbstractHandler shape so future logger refactors are exercised.

Minor (Nice to Have)

  1. resolveCaller returns unused authInfo field (src/controllers/api-key.js:147). All three call sites destructure only imsUserId. Drop the unused field, or comment-document a near-term use.

  2. 401 message variation enables state probing (src/controllers/api-key.js:113-130). Four distinct 401 messages let an authenticated caller distinguish "no org header", "no middleware", "profile incomplete", and "wrong org" - the org-membership message in particular lets a caller probe arbitrary IMS Org IDs to map their non-membership. Consider a single "Unauthorized" body for post-auth failures with detail only in x-error header / structured logs.

  3. createApiKey error log loses request context (src/controllers/api-key.js:217). Logs only error.message; no imsOrgId, no error stack. deleteApiKey does better. Pass imsOrgId (do NOT log imsUserId/email - PII).

Recommendations

  • Lift resolveCaller to a shared utility. This is the first instance of an (imsOrgId header -> authInfo -> hasOrganization -> imsUserId) pattern that 3+ other controllers will need as they migrate off imsClient.getImsUserProfile. Extracting now to src/support/auth-info-utils.js (or extending access-control-util.js) avoids drift on error wording, status code choice, and the imsUserId = profile.email aliasing logic. Easier while there is exactly one caller.
  • Add a Coralogix alert + dashboard tile on the migration log. The TODO(ASO-607) deletion plan rests on the api-key request authenticated via scoped IMS handler log dropping to zero. A 30-day rolling alert plus a paired alert on /tools/api-keys overall traffic prevents "log shipping broke last Tuesday" being mistaken for "no IaaS callers". Track in the ASO-607 ticket.
  • Constrain :id to UUID at the routing layer. DELETE /tools/api-keys/:id should reject non-UUID id early using the same isValidUUIDV4 regex used elsewhere in index.js. Reduces attack surface against ApiKey.findById and bounds the id length in log.error.
  • Validate data.features against an allowlist in validateRequestData (src/controllers/api-key.js:78-93). Currently a non-empty array of unknown values (e.g. ["bogus"]) passes validation and produces a key with scopes: {} - a broken record that consumes a maxApiKeys slot. Pre-existing behavior, not introduced by this PR, but adjacent to the touched code and a one-line fix: if (!data.features.every((f) => KNOWN_FEATURES.has(f))). Out-of-scope-strict; recommendation only.
  • Add a regression test for the hasOrganization semantic shift. AuthInfo.hasOrganization strips @AdobeOrg suffix and matches on tenant.id - different semantics from the old imsUserProfile.organizations.includes(imsOrgId) strict equality. Add a test feeding imsOrgId = 'test-org@AdobeOrg' against tenants [{ id: 'test-org' }] to lock the equality contract.
  • Document the username-prefix divergence in the OpenAPI spec / changelog. Customer-visible behavior change for IMS callers (key shape changes from <email-localpart>-<uuid> to <guid>-<uuid>).

Out of scope, worth tracking

  • Concurrent createApiKey race past maxApiKeys (read-modify-write without optimistic locking). Pre-existing behavior, not introduced here.
  • maxApiKeys=3 enforcement is per-(org, user); a determined attacker with N IMS users in the same org can amass 3N keys. Modeling decision belongs in a separate roadmap discussion.
  • ASO-607 worker migration trigger lives in a different repo.
  • Standardizing route-scoped auth handlers (a RouteScopedHandler(prefix, delegate) wrapper) belongs in spacecat-shared-http-utils, not here.

Assessment

Ready to merge? With fixes.

Reasoning: All prior findings are addressed and the code-level quality is high. The three Important items above are real concerns - identity divergence is a security-lifecycle gap (orphaned keys remain valid but unmanageable), the authInfo contract gaps are inexpensive defensive controls that close a future-fragility surface, and the test-stub fidelity drift undermines the migration signal's regression coverage. None are critical bugs in the running system, but the security-sensitive nature of the auth path means these warrant attention before merge or in a fast follow-up.

Next Steps

  1. Address the three Important issues - identity divergence at minimum needs an explicit ADR-level acceptance and observability signal; the authInfo whitelist + contract check is a small, defensive code change; the test stub fix is a one-liner.
  2. Pick up the Minor items as cleanup commits or roll into the ASO-607 work.
  3. Consider lifting resolveCaller now while there is exactly one caller.

Comment thread src/controllers/api-key.js Outdated
throw new ErrorWithStatusCode('Missing Authorization header', STATUS_UNAUTHORIZED);
const profile = authInfo.getProfile?.();
// While the property is named 'profile.email', for IMS auth this is the
// user_id (set by AdobeImsHandler.transformProfile, e.g. ABC123@AdobeID);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Important: Cross-protocol identity divergence creates orphaned, unmanageable keys.

The comment documents the profile.email shape asymmetry as intentional, but the operational consequence is a security-lifecycle gap. After an org migrates IMS->JWT (the ASO-607 trajectory), the same human presents imsUserId = real@adobe.com instead of ABC123@AdobeID, the ownership check at line 241-244 misses, getApiKeys returns empty, and deleteApiKey returns 404. The orphaned keys remain VALID via ScopedApiKeyHandler until 6mo expiry but cannot be rotated or revoked by their legitimate owner.

For IaaS-only orgs that never migrate, the divergence is the steady state - not a transitional artifact - which makes imsUserId an actively misleading field name.

Fix options (ascending invasiveness): (a) document the steady-state behavior explicitly via an ADR addendum + remap migration for ASO-607; (b) widen the ownership check to try both identity forms; (c) query both shapes in allByImsOrgIdAndImsUserId during the migration window and return the union.

Comment thread src/controllers/api-key.js Outdated
// for JWT auth it is the actual email. Either way, it is the stable
// per-user identifier we store as imsUserId on the ApiKey record. The
// username prefix derived below therefore differs in shape between IMS
// and JWT callers - this is intentional and documented.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Important: Strengthen the authInfo contract here. Two related defensive gaps:

(a) The controller's safety net against non-{IMS, JWT} handlers populating authInfo is implicit - it depends on alternate handler profile shapes lacking an .email field. Verified today: LegacyApiKeyHandler returns { user_id: 'admin' | 'legacy-user' }, ScopedApiKeyHandler returns the ApiKey entity. Neither has email, so profile?.email falls through to 401. But this is absence-of-fields safety, not a positive control. A future spacecat-shared schema change adding .email to either shape would silently turn a 401 into successful auth across handler types. Add a positive whitelist near line 117:

const allowed = ['ims', 'jwt'];
if (!allowed.includes(authInfo.getType?.())) {
  throw new ErrorWithStatusCode('Unauthorized', STATUS_UNAUTHORIZED);
}

(b) On this line, !authInfo.hasOrganization?.(imsOrgId) optional-chains a contract method. If hasOrganization is missing, the negation evaluates truthy and emits the misleading client-error 401 for what is actually a server-side authInfo-shape bug. Either gate explicitly with a typeof ... !== 'function' 500, or call unconditionally so the resulting TypeError propagates as a 500. Either makes the contract explicit and surfaces handler-shape regressions as 5xx, not 401.


it('does NOT emit the success log when super.checkAuth returns null (auth failed)', async () => {
superCheckAuthStub.resolves(null);
await handler.checkAuth({}, { pathInfo: { suffix: '/tools/api-keys' } });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Important: Stub diverges from production AbstractHandler.log; this assertion pins the wrong shape.

Production AbstractHandler.log (verified in spacecat-shared-http-utils) is this.logger[level](`[${this.name}] ${message}`) - it adds an [ims] prefix. So in production the logger actually receives [ims] api-key request authenticated via scoped IMS handler - JWT migration pending, but the stub at lines 38-40 strips the prefix and this calledOnceWithExactly asserts the bare message.

The test passes today but verifies less than its name implies. A future refactor to how the handler exposes its logger (e.g. someone overriding name or restructuring AbstractHandler) would keep the test green while the production migration signal output drifts.

Fix: align the stub to log[level](`[${this.name}] ${message}`) and update this assertion to match, or relax to calledWithMatch(/api-key request authenticated/). Better: make the stub a thin extension of the real AbstractHandler shape so future logger refactors are exercised.

…fo contract

Addresses three review findings on PR #2344:

- ApiKeyImsHandler now sets profile.email to profile.trial_email (the real
  address) so IMS and JWT callers store the same stable identity on ApiKey
  records, preventing orphaned keys when an IaaS org migrates to JWT.
- resolveCaller adds an explicit ['ims', 'jwt'] type whitelist and replaces
  the optional-chained hasOrganization call with a typeof guard that returns
  500 on a malformed authInfo, instead of a misleading 401.
- Handler test stub mirrors AbstractHandler's [ims] log prefix and adds
  coverage for the profile-email normalisation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ravverma
Copy link
Copy Markdown
Contributor Author

Thanks for the second pass — addressed all three Important findings in 02164436:

1. Cross-protocol identity divergenceApiKeyImsHandler.checkAuth now normalises profile.email to profile.trial_email (the real address) after super.checkAuth(). AdobeImsHandler.transformProfile aliases email to user_id and stashes the real address on trial_email; we undo that alias only inside this scoped handler. Both IMS and JWT callers now persist the same stable identity on ApiKey records, so keys stay manageable when an IaaS org migrates to JWT. Two new handler tests cover the normalisation and the trial_email-absent fallback.

2. authInfo contract hardening in resolveCaller

  • Added an explicit ['ims', 'jwt'] type whitelist; non-matching handler types get a clean 401 before reaching ownership logic. A future .email addition to LegacyApiKeyHandler/ScopedApiKeyHandler profiles can no longer silently grant access.
  • Replaced !authInfo.hasOrganization?.(imsOrgId) with a typeof guard that returns 500 when the method is absent, so handler-shape regressions surface as server errors instead of misleading 401s.
  • Two new controller tests cover both branches.

3. Test stub log-shape fidelity — The ApiKeyImsHandler test stub now mirrors AbstractHandler.log's [<name>] <message> shape, and the migration-signal assertion checks [ims] api-key request authenticated... to match production output.

Also tightened the controller comment on imsUserId to reflect that both auth paths now surface the real email.

All 36 tests across the two affected files pass; lint is clean.

@ravverma ravverma requested a review from solaris007 May 11, 2026 11:54
Copy link
Copy Markdown
Member

@solaris007 solaris007 left a comment

Choose a reason for hiding this comment

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

Hey @ravverma,

Thanks for the third-pass turnaround. The three Important findings from the 2026-05-08 review are all addressed cleanly in commit 0216443: the trial_email normalization restores cross-protocol identity continuity, the ALLOWED_AUTH_TYPES whitelist plus typeof guard make the authInfo contract explicit, and the test stub now mirrors the production [<name>] log prefix. The new commit is tightly scoped and the tests reflect the contract accurately.

No Important or Critical findings on this pass. A few Minor items below, mostly defense-in-depth around the new normalization path. Plus the identity-migration story for pre-existing keys is worth tracking before rollout.

Strengths

Prior Important findings from the 2026-05-08 review all addressed in 0216443:

  • Cross-protocol identity divergence: ApiKeyImsHandler.checkAuth now reads profile.trial_email (the server-attested real email, written by AdobeImsHandler.transformProfile) and overrides profile.email on the api-key path so IMS and JWT callers persist identical identities going forward. Two handler tests pin the normalize-when-present and leave-alone-when-absent behavior.
  • authInfo contract tightening: ALLOWED_AUTH_TYPES = ['ims', 'jwt'] whitelist at resolveCaller plus the typeof authInfo.hasOrganization !== 'function' guard convert two implicit safety nets into explicit contracts. The 500 vs 401 split correctly distinguishes server-side bugs from client-side auth failures. Two controller tests cover both branches.
  • Test stub fidelity: test/support/api-key-ims-handler.test.js stub now mirrors AbstractHandler's [<name>] <message> log shape, and the migration-signal assertion checks the [ims]-prefixed output - the test now pins the production contract.

Architecture-level observations:

  • The trial_email normalization is correctly scoped to the deprecated-path subclass (src/support/api-key-ims-handler.js:77-82). It does not modify the upstream AdobeImsHandler.transformProfile contract; it is a localized transitional fix that disappears when ASO-607 deletes this handler.
  • The ALLOWED_AUTH_TYPES whitelist comment (src/controllers/api-key.js:124-130) names the rejected handlers (LegacyApiKeyHandler, ScopedApiKeyHandler), the failure mode (silent fall-through to ownership lookup), and the future-proofing motivation. A maintainer six months from now will immediately understand why this guard exists.
  • The 500 vs 401 separation is operationally correct: a missing hasOrganization is a server-side contract bug that should page on a 5xx, not a client-facing 401 that gets silently absorbed.
  • Upstream contract verified: profile.trial_email is server-attested via context.imsClient.getImsUserProfile(token) in AdobeImsHandler.checkAuth, not derived from the bearer token's email claim. There is no JWT-confusion path for hijacking key ownership through trial_email.

Issues

Minor (Nice to Have)

  1. Profile mutation in place is a quiet contract reach. src/support/api-key-ims-handler.js:80-82

profile.email = profile.trial_email mutates the object returned by result.getProfile(). Today AuthInfo is constructed fresh per request inside AdobeImsHandler.checkAuth, so the mutation is request-scoped and the contract holds. But that is an unstated invariant of an external package: if @adobe/spacecat-shared-http-utils ever introduces caching on the IMS path (per-token memoization, profile cache), this mutation would silently leak across requests as a tampered email pinned onto a cached profile.

How to fix (any one):

  • Construct a fresh profile rather than mutating: result.withProfile?.({ ...profile, email: profile.trial_email }) if such a setter exists.
  • Derive the normalized email at the call site without mutating the shared object.
  • At minimum, add a one-line comment asserting "profile is per-request and not cached" so the invariant is documented at the mutation site, not just implied by spacecat-shared.
  1. 401 message variation continues to grow the state-probing surface. src/controllers/api-key.js:118-150

Minor 2 from the 2026-05-08 review is unresolved, and the new whitelist branch adds a fourth distinct 401 body ("Unauthorized") to the existing three ("auth middleware did not populate authInfo", "caller profile is missing email/user_id", "Unable to find a reference to the Organization provided"). The new 500 ("authInfo missing hasOrganization") is appropriate as a server-error message and not in scope for this concern.

An authenticated probe can now distinguish all four 401 paths, including org-membership which lets a caller test arbitrary IMS Org IDs against their org set. The detail is useful in logs but does not need to be on the wire.

How to fix: collapse the 4xx auth-failure bodies to a single sanitized "Unauthorized" response and emit the discriminating reason as a structured log line at the throw site (with imsOrgId, authType, reason). The 500 should continue to differ in status code but its body can be normalized too.

  1. Empty-string or null trial_email silently leaves the IMS alias on profile.email. src/support/api-key-ims-handler.js:80-82

The truthy check if (profile?.trial_email) skips normalization when trial_email is '' or null. In that path profile.email remains the IMS user_id alias (e.g. ABC123@AdobeID), the controller stores that as imsUserId, and the resulting ApiKey identity diverges from the JWT path - re-introducing the bug this PR is fixing.

I do not have a confirmed scenario where IMS returns an empty email (the upstream override in AdobeImsHandler.checkAuth reads imsProfile.email from the IMS profile API), but the failure mode is silent. The two existing tests cover trial_email present and trial_email absent (key missing entirely); neither covers '' or null.

How to fix: either (a) emit a warn log when trial_email is falsy-but-present so the divergence is observable for ASO-607 sign-off, and/or (b) add a third normalization test asserting profile.email is unchanged when trial_email: '' (and optionally null) so a future change to the guard cannot silently wipe identity.

Recommendations

  • When the next controller migrates off imsClient.getImsUserProfile, lift the resolveCaller pattern (whitelist + typeof guard + profile.email aliasing) into src/support/access-control-util.js as AccessControlUtil.resolveCallerIdentity(imsOrgId). The existing module already encodes isAccessTypeIms, isAccessTypeJWT, and hasOrganization helpers, so the resolveCaller logic naturally belongs there. Acting once there is a second caller avoids cross-controller drift on error wording and status code choices.
  • Pin the ASO-607 cleanup checklist with three deletion targets: (a) ApiKeyImsHandler registration in AUTH_HANDLERS, (b) ALLOWED_AUTH_TYPES whitelist collapse to ['jwt'] (or move to AccessControlUtil per the previous recommendation), (c) the trial_email-related comment in api-key.js around line 132 - it becomes wrong once the IMS path is removed. The design degrades gracefully but the cleanup is multi-step.
  • Track the migration-signal log in Coralogix with a rolling alert tied to /tools/api-keys overall traffic, so "log shipping broke last Tuesday" is not mistaken for "no IaaS callers." This was a prior recommendation; restating because ASO-607 sign-off depends on it.
  • Previously flagged Minor (resolveCaller returns unused authInfo field, all three call sites destructure only imsUserId) remains unresolved. Either drop authInfo from the return shape or comment-document it as exposed for future call sites - the function's contract is currently hard to test against because no callers exercise the authInfo half.
  • Add an integration test that walks the full auth path end-to-end: handler normalizes profile.email -> controller reads profile.email -> ApiKey record stores the real email. The split-level tests (handler unit, controller unit) verify each side of the contract independently but not the contract itself. Useful for ASO-607 sign-off.

Out of Scope, Worth Tracking

  • Identity migration for pre-existing IMS-created keys: keys persisted before this PR with imsUserId = <user_id>@AdobeID will not match an ownership lookup by real email after this PR ships. Affected users cannot list or rotate their existing keys via the API; the keys themselves remain valid bearer tokens until natural expiry (six months by default). A one-shot data migration (rewrite imsUserId by resolving user_id to real email via IMS profile lookup) or a release-note disclosure with operator playbook should accompany the rollout. Tracked under ASO-607 territory; not addressable in this PR.
  • S2S JWT callers will pass the ALLOWED_AUTH_TYPES whitelist. A service-to-service request authenticated via the JwtHandler S2S wrapper surfaces type='jwt' with the S2S JWT's email claim as profile.email. If machine identities are not supposed to create/own user-scoped API keys, the controller should additionally check !authInfo.isS2SConsumer() (or whatever the canonical predicate is). Confirm intent during the broader JWT migration.
  • AuthInfo.getProfile() returning a live profile reference rather than a defensive copy or a typed setter is the upstream affordance that makes the in-place mutation possible. A hardening pass on spacecat-shared-http-utils could close this footgun, but the contract change belongs there, not here.

Assessment

Ready to merge? Yes.

Reasoning: The third-pass changes correctly close the two material gaps from the 2026-05-08 review (identity divergence, authInfo contract) with appropriately scoped code, accurate comments, and matching test coverage. The remaining Minors are defense-in-depth and observability refinements that are well below merge-blocking severity on a third pass. The identity-migration story for pre-existing keys is the only operational item that warrants explicit tracking before customers notice; ASO-607 is the natural home.

Next Steps

  1. Optional: harden the profile mutation (Minor 1) or document the per-request invariant.
  2. Optional: collapse 4xx auth-failure response bodies (Minor 2) and add a structured audit-log line for observability.
  3. Optional: add the empty-string trial_email test (Minor 3).
  4. File an identity-migration ASO-607 sub-task before the rollout so existing IMS-keyed users aren't stranded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants