Skip to content

feat(http-utils): allow RO admin write ops on resources they own#1595

Merged
ravverma merged 12 commits into
mainfrom
feat/ro-admin-owned-resource-write
May 12, 2026
Merged

feat(http-utils): allow RO admin write ops on resources they own#1595
ravverma merged 12 commits into
mainfrom
feat/ro-admin-owned-resource-write

Conversation

@ravverma
Copy link
Copy Markdown
Contributor

@ravverma ravverma commented May 8, 2026

Problem

The readOnlyAdminWrapper previously blocked all write operations for read-only admin users. This was overly restrictive: a read-only admin whose IMS org owns a site or organization should be able to mutate that resource — they just should not be able to touch resources belonging to other orgs.

Solution

route-utils.js — new extractRouteParams

Added a focused, standalone extractRouteParams(context, routeMap) function that returns the named path parameters extracted from the matched route pattern (e.g. { siteId: 'abc-123' } from PATCH /sites/:siteId). The existing resolveRouteCapability is left completely unchanged so the s2s wrapper is unaffected.

read-only-admin-wrapper.js — ownership check before blocking writes

For write actions, before returning 403 the wrapper now calls isOwnerOfResource(context, authInfo, params):

  • If siteId is in the path params: fetches context.dataAccess.Site.findById(siteId), resolves the org, and calls authInfo.hasOrganization(org.getImsOrgId())
  • If organizationId is in the path params: fetches context.dataAccess.Organization.findById(organizationId) and checks the same
  • Fail-closed in all error cases: dataAccess absent, entity not found, no recognisable ID in the path, or any DB exception — the exception is logged via log.error and the request is blocked

Read paths are completely untouched.

context.data fallback for ID-less routes

Some write routes carry no resource ID in the path but pass it in the request body instead (e.g. POST /preflight/jobs sends siteId in the JSON payload, resolved via context.data.siteId). For these cases isOwnerOfResource falls back to context.data after finding no match in the route params:

const siteId = params.siteId ?? context.data?.siteId;
const organizationId = params.organizationId ?? context.data?.organizationId;

Path params always take precedence over body data when both are present. Routes that carry neither (e.g. POST /sites creating a new resource) still return false and are blocked.

Test coverage

route-utils.test.js — 8 new cases for extractRouteParams: exact match (no params), single param, multiple nested params, org param, no match, missing method/suffix/pathInfo.

read-only-admin-wrapper.test.js — 15 new cases under "write on owned resource":

  • Owned site write (path param) → allowed + audit log emitted
  • Unowned site write → 403
  • Site not found → 403
  • Site with no org → 403
  • Owned org write (path param) → allowed
  • Organization not found → 403
  • Unowned org write → 403
  • No ID in path and no context.data (e.g. POST /sites) → 403
  • No dataAccess on context → 403 (fail-closed)
  • DB lookup throws → 403 + log.error called with the error (fail-closed)
  • Owned site write via context.data.siteId (no path param) → allowed
  • Unowned site via context.data.siteId → 403
  • Owned org write via context.data.organizationId (no path param) → allowed
  • Path param takes precedence over context.data when both present
  • Audit log emitted on allowed owned write

All 309 tests pass. Coverage: 100% lines/statements, 98.18% branches (threshold: 97%).

Checklist

  • resolveRouteCapability untouched — s2s wrapper unaffected
  • Fail-closed for every error path in the ownership check
  • Exceptions logged before returning false (no silent failures)
  • Ownership check is lazy — only runs for write routes, never for reads
  • context.data fallback handles routes like POST /preflight/jobs with no ID in path
  • Path params take precedence over body data when both are present
  • No new package dependencies

🤖 Generated with Claude Code

ravverma and others added 2 commits May 8, 2026 08:21
Read-only admins were previously blocked from ALL write operations by
the readOnlyAdminWrapper, including mutations on sites and organizations
that belong to their own IMS org. This change lifts that restriction
when ownership can be confirmed from the request path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rOfResource

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

This PR will trigger a minor release when merged.

… in route path

For write routes with no :siteId or :organizationId path param (e.g.
POST /preflight/jobs), isOwnerOfResource now falls back to context.data
to resolve the target resource before performing the ownership check.
Path params still take precedence over body data when both are present.

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

ravverma commented May 8, 2026

Self reviewed with review kit

All three agents completed. Here's the consolidated review:


PR #1595 Review Summary

Critical (fix before merge)

[security] Body fallback activates on param name mismatch, not just "no params in route"
read-only-admin-wrapper.js:52-53

params.siteId ?? context.data?.siteId fires whenever params.siteId is absent — including when the route has a path param but it's named :id, :site, or anything other than :siteId. An RO admin who owns site A could call PATCH /sites/B (a route using :id) with body { siteId: "A" } and the ownership check would pass for A while the handler writes to B.

Fix: only fall back to context.data when the route had zero path params — i.e. gate on Object.keys(params).length === 0:

const siteId = Object.keys(params).length === 0
  ? context.data?.siteId
  : params.siteId;
const organizationId = Object.keys(params).length === 0
  ? context.data?.organizationId
  : params.organizationId;

Important (should fix)

[observability] evaluateFeatureFlag catch is completely silent
read-only-admin-wrapper.js:108-110 — LD failures block all RO admin access with zero log output. Add log.error before return false.

[observability] Missing dataAccess returns false with no log
read-only-admin-wrapper.js:46-48 — A misconfigured wrapper chain produces a flood of unexplained 403s. Add log.error with a message pointing at wrapper ordering.

[observability] No-ID-found case is silent and indistinguishable from ownership failure
read-only-admin-wrapper.js:80 — When neither path nor body has a recognisable ID, return false fires with no log. Operators can't tell if a 403 came from "user doesn't own this" vs "no ID was found at all". Add a log.warn.

[audit] Audit log omits resolved resource ID and whether body fallback was used
read-only-admin-wrapper.js:177-183 — The ro-admin-audit log entry doesn't record which siteId/organizationId was used to authorise a write, making post-incident reconstruction impossible. Include the resolved IDs and source ('path' vs 'body').

[reliability] extractRouteParams called outside any try-catch
read-only-admin-wrapper.js:162 — If it throws, the exception escapes the wrapper as an unhandled rejection rather than a controlled 403. Wrap it.


Suggestions (nice to have)

[test] getOrganization() throwing is not covered — the catch block is exercised via findById throwing but not from a second async step inside the same try block.

[test] Organization.findById throwing is not covered — only the site lookup throw path is tested; the org path hits the same catch but isn't explicitly exercised.

[test] dataAccess present but Site/Organization property absent not tested — the optional chaining dataAccess.Site?.findById silently returns undefined; no test exercises this with context.dataAccess = {}.

[docs] JSDoc should document that context.data must be the parsed body — if readOnlyAdminWrapper runs before the body parser in the .with() chain, body-fallback routes will always 403 with no diagnostic.


Strengths

  • Fail-closed is correctly implemented and well-tested for the main paths
  • Path params take precedence over body data via ?? — correct priority
  • Ownership check is lazy (only runs on write routes)
  • 309 tests, 100% lines/statements, 98.18% branches
  • isOwnerOfResource catch block correctly logs the error before returning false
  • extractRouteParams returns {} (never null) — prevents null-dereference downstream

Security:
- Body fallback now only applies when the route has zero path params,
  preventing a caller from spoofing ownership by supplying a body siteId
  on a route that uses a differently-named path param (e.g. :id vs :siteId)

Observability:
- evaluateFeatureFlag catch now logs log.error before returning false
- isOwnerOfResource logs log.error when dataAccess is absent
- isOwnerOfResource logs log.warn when no ID can be resolved from path or body
- New ro-admin-write audit log emitted on allowed writes, including resolved
  resource IDs and whether they came from the path or request body
- extractRouteParams call wrapped in try-catch with log.error on failure

Tests:
- Asserts log.error on LD SDK throws and isFlagEnabledForIMSOrg throws
- Asserts log.error on missing dataAccess; log.warn on no-ID-found
- Covers getOrganization() throwing, Organization.findById throwing,
  dataAccess present but Site property absent
- Security test: verifies body siteId is ignored when route uses :id param
- Asserts ro-admin-write log fields (idSource path vs body)
- Covers extractRouteParams throwing via esmock

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

ravverma commented May 8, 2026

Pushed. Here's a summary of everything addressed from the review:

Critical (security)

  • Body fallback now gates on Object.keys(params).length === 0 — it only activates when the matched route has no path params at all, closing the bypass where an attacker on a :id-named route could supply a body.siteId for a site they own

Observability / logging (4 gaps fixed)

  • evaluateFeatureFlag catch now calls log.error with the exception before returning false
  • isOwnerOfResource logs log.error when dataAccess is absent (with a pointer to wrapper ordering)
  • isOwnerOfResource logs log.warn when no ID can be resolved from path or body
  • Allowed writes now emit a dedicated ro-admin-write log entry including resolved IDs and idSource: 'path' | 'body'

Reliability

  • extractRouteParams call wrapped in try/catch — exceptions now produce a controlled 403 + log.error instead of an unhandled rejection

New tests (9 added)

  • log.error assertions on LD SDK throw and isFlagEnabledForIMSOrg throw
  • log.error on missing dataAccess; log.warn on no-ID-found
  • getOrganization() throwing (fail-closed + log)
  • Organization.findById throwing (fail-closed + log)
  • dataAccess present but Site property absent
  • Security test: body siteId ignored when route uses :id param name
  • ro-admin-write log assertions for both path and body ID source
  • extractRouteParams throwing via esmock

ravverma and others added 3 commits May 11, 2026 10:41
…t.params

Router already extracts path params into context.params; re-parsing the
URL suffix against routeMap was redundant work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… use access log tag

- For parameterized routes, ownership is now checked before capability
  so RO admins who own a resource are never wrongly denied on unmapped routes
- Rename log tag ro-admin-write → ro-admin-access and message to
  'RO admin access allowed on owned resource' since the operation can be a read

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wnership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bhellema bhellema requested a review from solaris007 May 11, 2026 14:06
ravverma and others added 2 commits May 11, 2026 21:55
context.params is set by the router only inside the inner handler (run),
which executes after readOnlyAdminWrapper. So context.params is undefined
at wrapper time and the earlier simplification was incorrect. Restore the
URL-suffix-against-routeMap extraction.

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

Hey @ravverma,

Thanks for the careful design and the detailed self-review trail. The fail-closed posture is solid, body-fallback bypass via :id-style routes is correctly closed and locked in by tests, and the audit fields (resolvedSiteId, resolvedOrgId, idSource) are exactly what an on-call needs. A few concerns worth tightening before merge.

Strengths

  • read-only-admin-wrapper.js:50-99 isOwnerOfResource fail-closes consistently across every error path (missing dataAccess, entity not found, missing org, lookup throws, no resolvable ID) and structured-logs each pathway.
  • read-only-admin-wrapper.js:62-66 correctly gates body fallback on Object.keys(params).length === 0. The regression test "blocks write and ignores body siteId when route has path params with a different name" (read-only-admin-wrapper.test.js:614-629) locks this in.
  • route-utils.js:76-103 extractRouteParams reuses the existing matchRoute helper, so the matcher remains single-sourced.
  • Coverage is thorough on the explicit cases: site/org/spaceCatId path params, body fallback, path-vs-body precedence, db throws on both branches, missing Site accessor, and getOrganization throws.

Issues

Important (Should Fix)

  1. read-only-admin-wrapper.js:69, 77 - hasOrganization(undefined) would raise TypeError. auth-info.js:95-100 does orgId.split('@') with no guard. If org.getImsOrgId() returns falsy (misconfigured org row, missing IMS provisioning), the call throws and the outer try/catch at line 88 catches it as a generic "Error checking resource ownership for RO admin". Fail-closed posture is preserved but the log message is misleading and masks a real data-integrity bug. Fix: guard getImsOrgId() explicitly before passing to hasOrganization, with a dedicated log.warn like "Owning organization has no imsOrgId; denying RO admin write". Optional but recommended: also guard hasOrganization itself in auth-info.js against null/undefined input.

  2. read-only-admin-wrapper.test.js:673-699 - two tests claim to cover "unmapped routes" but actually exercise the parameterized-match path. Suffix /sites/some-unmapped-action matches PATCH /sites/:siteId in the routeMap, so extractRouteParams returns { siteId: 'some-unmapped-action' } and the body siteId: 'abc-123' is never consulted. The tests pass only because findById is a permissive stub. As a result, truly unmapped routes are not test-covered. Fix: use a suffix that genuinely fails to match (e.g. /some/other/path against a routeMap containing only PATCH /sites/:siteId) and assert findById.calledWith('abc-123') to lock in body-fallback semantics on unmapped routes.

  3. read-only-admin-wrapper.js:212-240 - unmapped routes reach the body-fallback authorization path. When a request hits a URL whose pattern is not in routeCapabilities at all (extractRouteParams returns {} because no key matches), control falls into the !hasPathParams else branch, resolveRouteCapability returns null, the action is undefined (not 'read'), and isOwnerOfResource consults context.data.siteId. If the body carries an owned siteId, the request is allowed. Net effect: a new admin endpoint shipped in a consumer service without a corresponding routeCapabilities entry becomes write-reachable for owners. The previous safe default was "unmapped means deny." Fix options: (a) require resolveRouteCapability(...) to return non-null before letting body-fallback authorize, or (b) introduce an opt-in flag per route (e.g. capability suffix :bodyOwnership) so consumers must declare which body-driven writes are sanctioned.

  4. read-only-admin-wrapper.js:178-211 - non-owner reads on parameterized routes now trigger isOwnerOfResource, which performs a DB lookup before the read can proceed. Two concrete consequences: (a) every parameterized-route read by an RO admin now does an extra Site.findById + getOrganization() (or Organization.findById) even though reads were always permitted by capability; (b) in any caller setup where dataAccess is not yet on the context (e.g. some legacy/test wirings), this emits log.error("dataAccess not on context...") per read, polluting the noise floor. Fix: in the hasPathParams branch, resolve capability first; if the action is read/readAll, skip the ownership check entirely. Reserve ownership-first for write actions.

  5. read-only-admin-wrapper.js:184-249 - double info log per allowed RO admin request. Both ro-admin-access and ro-admin-audit are emitted for the same request with overlapping fields. This doubles audit log volume on a privileged code path and breaks downstream consumers that expect one log line per request. Pick one tag (the richer ro-admin-access subsumes ro-admin-audit), or skip the audit log when the access log was already emitted.

  6. read-only-admin-wrapper.js:172-243 - confused-deputy shape on body-fallback writes. The wrapper authorizes based on a single siteId/organizationId plucked from the body, but the handler may mutate other resources that the wrapper never validated. Concrete example: POST /preflight/jobs with body { siteId: "owned-A", targets: ["unowned-B"] } is allowed because the user owns site A; the handler may then operate on unowned-B. The wrapper has no visibility into the handler's actual action surface. Fix: document the contract explicitly on readOnlyAdminWrapper's JSDoc that handlers behind body-fallback authorization MUST only mutate the declared siteId/organizationId. Consider an explicit per-route opt-in (issue 3) so consumers must surface this contract per endpoint.

  7. route-utils.test.js:83-128 - extractRouteParams has no direct unit test for :spaceCatId extraction even though the wrapper special-cases it. Coverage is only transitive through read-only-admin-wrapper.test.js. Add one direct case: route 'PATCH /v2/orgs/:spaceCatId', suffix /v2/orgs/org-456, expect { spaceCatId: 'org-456' }.

  8. read-only-admin-wrapper.test.js:599-612 - the body-fallback success test asserts the ro-admin-access log but not the trailing ro-admin-audit log. The path-fallback test at line 579 asserts both. Add the matching ro-admin-audit assertion for symmetry; otherwise a future refactor that accidentally early-returns after ro-admin-access on the body path would silently drop the audit log.

  9. read-only-admin-wrapper.js:166-208 - denial-reason ambiguity in audit logs. The same tag: 'ro-admin' and 'Read-only admin blocked from route' message is emitted on FF-disabled denials (line 166), parameterized non-owner-write denials (line 202), and collection-write no-owner denials (line 220). A SIEM cannot distinguish "feature flag off" from "not the owner" from "no resource ID in request" - exactly the events that warrant separate detection rules. Add a reason field (e.g. 'feature-flag-disabled' | 'not-owner-and-no-read-capability' | 'no-resource-id').

Minor (Nice to Have)

  • read-only-admin-wrapper.js:62-66 - dead-code in the no-path-params branch. When hasPathParams === false, params.siteId / params.organizationId / params.spaceCatId are guaranteed undefined, so params.X ?? context.data?.X collapses to context.data?.X. Cleaner:

    const siteId = hasPathParams ? params.siteId : context.data?.siteId;
    const organizationId = hasPathParams
      ? (params.organizationId ?? params.spaceCatId)
      : (context.data?.organizationId ?? context.data?.spaceCatId);
  • read-only-admin-wrapper.js:175 - extractRouteParams is called outside any try/catch. Today the function is pure string ops on pathInfo.suffix so a throw is unlikely, but a single wrapping try/catch around the whole authorization block would be consistent with the fail-closed posture the rest of the wrapper enforces.

  • read-only-admin-wrapper.js:204-205, 235-236 - resolvedSiteId/resolvedOrgId in the body-fallback log are re-derived from context.data rather than reflecting what isOwnerOfResource actually used. If isOwnerOfResource's precedence ever changes (or aliases expand), the log will drift from the policy. Consider having isOwnerOfResource return { owns, resolvedSiteId, resolvedOrgId, idSource } so the wrapper logs the same value the policy used.

  • read-only-admin-wrapper.js:55-66, 191, 236 - spaceCatId is referenced only in this PR within spacecat-shared. The convention may come from spacecat-api-service LLMO routes (e.g. :spaceCatId for org identifiers), but a new maintainer reading the code in 12 months will reasonably wonder if it is a typo. Add a brief comment at the first alias site naming the consuming convention.

  • read-only-admin-wrapper.js:140-156 - JSDoc on readOnlyAdminWrapper does not state two load-bearing wiring requirements: (a) dataAccessWrapper must run before this wrapper, (b) the request body parser must run before this wrapper for routes that depend on body fallback. Mis-ordered .with() chains will silently deny legitimate requests. Add both to the JSDoc.

  • route-utils.test.js:83-128 - missing edge cases for extractRouteParams: trailing slash in suffix (/sites/abc/), method-mismatch (route is PATCH, request is POST), lowercase request method (pathInfo.method = 'patch'), longer request than route pattern (/sites/abc/extra vs /sites/:siteId). The implementation appears correct via routeSegments.length !== requestSegments.length and toUpperCase(), but each is a load-bearing assumption worth locking in.

Recommendations

  • Pick a single authorization contract for body-fallback routes (issue 3 above) and document it in the routeCapabilities map convention. The two viable options are explicit opt-in per route, or "must be in routeCapabilities with a non-null capability." The current behavior of "body fallback authorizes any route" is the easiest mode to maintain but the riskiest as the consumer set grows.
  • Consider returning the resolved entity from isOwnerOfResource and stashing it on context (e.g. context.authzResource = { site, org }). This both eliminates a duplicate fetch by the handler and gives the handler a verified resource to operate on - which slightly tightens the confused-deputy shape from issue 6.

Assessment

Ready to merge? With fixes.

Reasoning: The security model and fail-closed posture are correct; the body-fallback bypass concern raised earlier in self-review is properly closed and tested. The remaining items fall into two clusters: (1) policy decisions that should be tightened before this lands as the library-wide pattern (unmapped-route body authorization, ownership check on every read, confused-deputy doc/contract); and (2) test/observability hygiene that's straightforward to address (test renames, audit log split, denial-reason field, :spaceCatId unit coverage).

Next Steps

  1. Address items 1-3 first (TypeError path, misleading test names, unmapped-route authorization) - these are the load-bearing correctness/policy items.
  2. Pick a fix for items 4-6 (read fast-path, log doubling, confused-deputy contract).
  3. Items 7-9 are small, mechanical test/log changes.
  4. Minor items optional but high-leverage for future maintainers.

- Guard imsOrgId before calling hasOrganization to prevent TypeError
  on misconfigured orgs (both siteId and organizationId paths)
- Fast-path read routes before ownership check — no DB lookup needed
  for capability read/readAll actions
- Suppress duplicate ro-admin-audit log when ro-admin-access was
  already emitted (accessLogged flag)
- Fix misleading unmapped-route tests to use a suffix that genuinely
  does not match any routeCapabilities entry; assert findById is called
  with the body siteId to lock in body-fallback semantics
- Add imsOrgId null-guard tests for both site and org paths
- Add outer try/catch coverage via esmock making extractRouteParams throw
- Add spaceCatId extraction test and extractRouteParams edge cases
  (method mismatch, lowercase method, extra segments, trailing slash)
- Document body-fallback contract in JSDoc; add reason field to denial logs

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

Hey @ravverma,

Re-review on commit f4488a52. All previously flagged Important findings are addressed; the pushed-back policy on unmapped routes is documented and defensible. One residual contract-legibility concern and a small batch of observability/test polish notes.

Strengths

Previously flagged issues now resolved:

  • hasOrganization(undefined) TypeError guard: explicit imsOrgId null check in both site (read-only-admin-wrapper.js:78-83) and org paths (:91-96), each with dedicated log.warn and dedicated tests.
  • Misleading "unmapped route" tests fixed: suffix changed to POST /jobs/preflight; findById.calledWith('abc-123') now locks in body-fallback semantics (read-only-admin-wrapper.test.js:738-768).
  • Read fast-path implemented (read-only-admin-wrapper.js:217). No more Site.findById + getOrganization per parameterized read.
  • Double-log suppressed via accessLogged flag; test asserts ro-admin-audit is NOT emitted when ro-admin-access fired (read-only-admin-wrapper.test.js:643-662).
  • Confused-deputy contract now explicit in the wrapper JSDoc (read-only-admin-wrapper.js:179-183): handlers behind body-fallback MUST only mutate the declared siteId/organizationId.
  • :spaceCatId direct unit test at route-utils.test.js:133-137.
  • Body-fallback path-param spoofing remains blocked (:62) and tested at read-only-admin-wrapper.test.js:679-694.
  • JSDoc on readOnlyAdminWrapper now documents wiring requirements (dataAccessWrapper first, body-parser first for body-fallback routes).
  • Outer try/catch around the whole authorization block (read-only-admin-wrapper.js:209-244); tested via esmock at read-only-admin-wrapper.test.js:770-793.
  • extractRouteParams edge cases (method mismatch, lowercase method, longer-than-pattern, trailing slash) all covered.
  • Pushed-back item (unmapped-route ownership policy): the author's counter-argument is reasonable given the dual gating (IMS is_read_only_admin claim + FT_READ_ONLY_ORG per-org LD flag) and the comprehensive ro-admin-access audit log. Policy is documented at read-only-admin-wrapper.js:213-216. Original concern resolved by author response; see Important item 1 for the residual contract-legibility note.

Issues

Important (Should Fix)

  1. Drift-detection signal needed when an unmapped route is allowed via ownership (read-only-admin-wrapper.js:213-244). The policy ("ownership is the governing invariant, capability mapping is advisory") is defensible at the wrapper level, but the contract becomes invisible to consumers: a maintainer in spacecat-api-service or spacecat-audit-worker reading the routeCapabilities map sees what looks like an explicit allowlist, while routes omitted from the map are silently allowed if ownership resolves. The concrete failure mode is a maintainer removing a route from routeCapabilities thinking it is dead - behavior silently changes from "denied" to "allowed if owner", and local unit tests cannot detect this because the map is consumer-supplied.

    Fix (minimal): emit a log.warn when ownership grants access to a route that was NOT in routeCapabilities:

    if (capability === null) {
      log.warn({ tag: 'ro-admin', reason: 'unmapped-route-allowed', method, suffix }, 'RO admin allowed on unmapped route via ownership');
    }

    Consumer maintainers can grep for this in production logs to detect drift.

    Fix (better, separate PR): add a sentinel value in routeCapabilities for routes that intentionally rely on body-fallback ownership (e.g. 'POST /preflight/jobs': 'preflight:body-ownership') so the contract stays a single-source-of-truth lookup.

Minor (Nice to Have)

  1. Denial-reason field is misleading for the "no resolvable ID" path. read-only-admin-wrapper.js:103-107 + :235-242. When isOwnerOfResource finds no siteId and no organizationId, it returns false and the wrapper logs reason: 'not-owner', which is factually wrong. Either return a structured result from isOwnerOfResource ({ owner, reason: 'no-id' | 'not-owner' | 'lookup-failed' }) or differentiate at the call site.

  2. FF-disabled denial lacks reason field. read-only-admin-wrapper.js:192-196 still uses generic tag: 'ro-admin' without a reason, while the not-owner denial carries reason: 'not-owner'. Add reason: 'feature-flag-disabled' for SIEM filterability.

  3. Outer-try test missing body and audit-log assertions. read-only-admin-wrapper.test.js:770-793 asserts result.status === 403 and the error log, but not body.message === 'Forbidden' and not that ro-admin-audit is NOT emitted on the catch path. Add both for symmetry.

  4. Sibling block tests should assert reason: 'not-owner'. read-only-admin-wrapper.test.js:256-266 (POST write-route) and :294-304 (unmapped route) flow through the same not-owner branch as the PATCH test at :277-280 but only assert { tag: 'ro-admin' }. Extend both to include reason: 'not-owner'.

  5. resolvedSiteId/resolvedOrgId in the log are re-derived from context.data rather than tracked through isOwnerOfResource. read-only-admin-wrapper.js:226-232. Resolution rules in isOwnerOfResource and in the log block must stay synchronized; today they are literal duplicates. Have isOwnerOfResource return { owner, resolvedSiteId, resolvedOrgId, idSource }.

  6. extractRouteParams matches paths with double internal/leading slashes. Because filter(Boolean) collapses empty segments, //sites/abc-123 and /sites//abc-123 match the parameterized route. Behavior is currently fine but the trailing-slash test at route-utils.test.js:155-158 only pins one such case. Add one paranoia test for double-slash inputs.

Recommendations

  • Return the resolved entity from isOwnerOfResource so handlers can reuse the verified site/org instead of re-fetching.
  • Per-route opt-in for body-fallback authorization (sentinel value or richer entry shape). Tracking only, separate PR.
  • Consider replacing email with a stable IMS user ID in the ro-admin-access/ro-admin-audit logs - email is PII at four log call sites.

Assessment

Ready to merge? With fixes.

Reasoning: The new commit substantively addresses every previously-flagged Important finding and the pushed-back policy is documented and defensible. The remaining Important item is operational: add a one-line drift-detection log. Minor items are mechanical observability and test-symmetry tightening.

Note: CI Test check is currently pending - re-verify before merging.

Next Steps

  1. Pick up the Important item first (one log line at the unmapped-route allow path).
  2. Minor items 1-2 (denial-reason on FF-disabled and no-id paths) are the highest-signal of the remaining items for forensics.
  3. Minor items 3-4 are mechanical test additions.

…d via ownership

When an RO admin is granted access to a route not listed in
routeCapabilities (capability === null), the wrapper now emits a
log.warn with reason 'unmapped-route-allowed'. This surfaces
routeCapabilities drift to consumers without changing authorization
semantics.

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

Hey @ravverma,

Re-review on commit 6134ad6c. The prior Important finding (drift-detection log for unmapped-route ownership) is addressed. Implementation is faithful to the recommended fix; placement, severity, structured fields, and message text are all correct. One small test-symmetry suggestion and two observability follow-ups. No merge blockers.

Strengths

Previously flagged issue now resolved:

  • Drift-detection signal for unmapped-route-via-ownership (read-only-admin-wrapper.js:219-227): exactly the recommended fix. Gated on capability === null so it fires only on the allow-path for unmapped routes. log.warn severity is right for a policy-drift event. Structured fields (tag, reason: 'unmapped-route-allowed', method, suffix) are SIEM-filterable and symmetric with the existing reason: 'not-owner' denial signal. Message names the remediation. No PII or token leakage beyond what the neighbouring ro-admin-access log already emits.
  • Test coverage (read-only-admin-wrapper.test.js:754-772): positive assertion via calledWithMatch on the exact payload and message, using a realistic body-fallback path (POST /jobs/preflight).

Issues

Minor (Nice to Have)

  1. No negative assertion that the drift warn is suppressed on mapped writes. The production gate is if (capability === null). The existing mapped-write owner-allowed test (around read-only-admin-wrapper.test.js:643) is the natural home for expect(logStub.warn.calledWithMatch({ reason: 'unmapped-route-allowed' })).to.be.false. Without it, a future flip of the condition (dropping the === null, or changing to == null) would not be caught. One line; locks the gate.

  2. authInfo.getTenantIds?.()[0] shape on the new log line (read-only-admin-wrapper.js:224). If getTenantIds is absent the optional chain yields undefined and [0] throws TypeError; the outer try/catch would absorb it and the drift signal would be silently lost. Note: this pattern is pre-existing on lines 195, 226, 241, 261, so the new line just adds another instance. Consider hardening to authInfo.getTenantIds?.()?.[0] across all five call sites (or tracking as a follow-up; not blocking for this commit).

Recommendations

  • Fold reason: 'unmapped-route-allowed' (or a capabilityMapped: false boolean) into the existing ro-admin-access log.info entry rather than emitting a separate warn. SIEM and dashboards can then detect drift from a single log stream per request instead of correlating two lines.
  • Treat the drift warn as a closeable signal in consumer-service dashboards: explicit goal of "zero unmapped-route-allowed events sustained for N days". Once empirically empty, the wrapper contract simplifies from "ownership is the invariant, capability is advisory" to "capability mapping is required", which is a strictly smaller surface to explain. Worth a follow-up ADR.

The previously raised minor items from the last re-review (denial-reason wording, FF-disabled reason field, outer-try test polish, sibling not-owner test reason assertion, resolvedSiteId/resolvedOrgId re-derivation, double-slash path matching) remain unresolved but are not gating; they were not picked up by this commit and can land in a separate cleanup.

Assessment

Ready to merge? Yes.

Reasoning: The sole gating Important item from the prior re-review is addressed precisely as suggested. The new branch is fully covered by the new positive test; the one Minor on negative assertion is a one-line test addition and not blocking. The pre-existing getTenantIds?.()[0] pattern is a separate hygiene fix worth tracking but not gating.

Note: CI Test check was pending at review time, re-verify before merging.

…or unmapped methods

When a request method has no entry in routeCapabilities (e.g. PUT
/sites/:siteId when only PATCH /sites/:siteId is listed),
extractRouteParams previously returned {} causing ownership checks to
fall back to the request body — which is absent for methods like PUT or
DELETE, silently denying RO admins who own the resource.

readOnlyAdminWrapper now accepts an optional internalRoutes array of
route pattern strings for routes that exist in the service but are not
listed in routeCapabilities. extractRouteParams tries these as a
fallback after the routeCapabilities match fails so path params can be
resolved and the ownership check can run against the correct resource ID
from the URL.

Also removes the now-redundant exactKey early-return in extractRouteParams
(lines 89-92) that blocked fallbackRoutes from being reached when an
exact-key hit occurred in routeMap. The parameterized matching loop
already handles exact-match routes correctly (no :param segments →
params = {}).

The existing capability === null drift-detection warn fires whenever an
internalRoutes-resolved request is allowed, signalling the consumer to
add the route to routeCapabilities for explicit capability control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 diligent self-review iterations and the depth of test coverage. The fail-closed posture, the explicit spoofing test for :id-named routes, the drift-detection log, and the read fast-path all show careful security thinking. That said, this first formal review surfaces one Critical bypass path and a cluster of Important issues around the design surface. The Critical issue is in scope to fix on this branch with a small change.

Strengths

  • Fail-closed exhaustiveness is consistent across every error path I traced: missing dataAccess, missing Site/Organization accessor, null entity, null imsOrgId, lookup throws, FF eval throws, and unexpected exceptions in the wrapper body (read-only-admin-wrapper.js:53-100, 137-141, 256-267).
  • The hasPathParams === false gate closes the param-name spoofing attack for routes that ARE in routeCapabilities with non-:siteId param names. The test at read-only-admin-wrapper.test.js:786 is the right test to have.
  • The imsOrgId == null guard before hasOrganization(...) prevents the TypeError that auth-info.js would throw on undefined.split('@').
  • Read fast-path skips the DB lookup for read/readAll capabilities.
  • Drift-detection unmapped-route-allowed warn gives operators an actionable signal (though see Important item 1 for why detection alone is not enough here).
  • The accessLogged flag cleanly prevents double audit-log emission.
  • Wrapper-ordering requirements (dataAccessWrapper and body-parser before this wrapper) are explicit in JSDoc rather than left as folklore.
  • 538 lines of tests with strong negative-case coverage, including DB throws on both site and org paths and the getOrganization() throw path.

Issues

Critical (Must Fix)

Body-fallback bypass for completely unmapped path-resource routes (read-only-admin-wrapper.js:63-66)

The hasPathParams === false gate depends on the wrapper's own route lists (routeCapabilities + internalRoutes) being exhaustive. When a write route lives in NEITHER list but its URL contains a resource segment, extractRouteParams returns {} (no match anywhere), hasPathParams is false, the body fallback consults context.data, and the wrapper authorizes against the body-claimed resource while the handler may operate on the URL-claimed resource. This is a confused-deputy / IDOR pattern.

Attack scenario:

  1. A developer adds PUT /sites/:siteId/transfer-ownership to spacecat-api-service. They forget to add it to either routeCapabilities or internalRoutes.
  2. Mallory (RO admin) owns site mallory-site. She wants to attack victim-site in another IMS org.
  3. Mallory sends PUT /sites/victim-site/transfer-ownership with body {"siteId":"mallory-site"}.
  4. extractRouteParams returns {} (route in neither list); hasPathParams = false.
  5. Body fallback: siteId = 'mallory-site'. Site.findById('mallory-site') resolves to Mallory's org. hasOrganization(...) returns true.
  6. capability === null -> drift log.warn fires; ro-admin-access log records idSource: 'body', resolvedSiteId: 'mallory-site'.
  7. Wrapper calls fn(request, context). The handler reads its own context.params.siteId from the URL = 'victim-site' and mutates the victim site.

The wrapper authorized against the body claim; the handler operated on the URL claim. The drift log.warn fires but does NOT block - logs are detection, not control.

The existing test at read-only-admin-wrapper.test.js:729 ("allows access on unmapped route when siteId is supplied via body") uses suffix /jobs/preflight - no resource segment, no victim. The PUT /sites/:siteId/<unmapped> + body-siteId combination is not tested.

Fix (recommended): only allow body fallback when capability !== null. This preserves the legitimate POST /preflight/jobs case (mapped capability, no path params, body siteId is the resource) while denying ANY unmapped route, with or without resource segments. Add a test asserting PUT /sites/<victim-id>/<unmapped-action> with body {siteId: '<owned-id>'} returns 403 and that Site.findById is NOT called with the body siteId.

Alternative fixes: distinguish "no match" from "matched-with-no-params" in extractRouteParams (e.g. return null vs {}), or introduce an explicit bodyFallbackRoutes allowlist.

Important (Should Fix)

1. Permissive default for unmapped writes erodes the explicit-allow-list model (read-only-admin-wrapper.js:223)

The "ownership-first" choice (capability=null + isOwner=true ALLOWED, with a drift warn) inverts the safe default for a security wrapper. Owning a site is not a generalized authorization claim for every operation defined on that site. RO admin is bounded by FT_READ_ONLY_ORG and capability mapping precisely so that adding sensitive operations requires deliberate policy. A new destructive endpoint added without a routeCapabilities entry is callable by any RO admin who owns the target, with only a log.warn to flag it.

Recommended fix (also addresses the Critical and Important 4): when capability === null, deny unmapped writes regardless of ownership. The "RO admin can write resources their org owns" property is then expressed by explicitly mapping the route in routeCapabilities. internalRoutes becomes unnecessary - consumers add new routes to one map only.

If you keep the permissive default, please elevate the drift signal from log.warn to an alert or fail-closed in non-prod.

2. routeCapabilities non-object input silently fails open (route-utils.js:115, read-only-admin-wrapper.js:204)

guardNonEmptyRouteCapabilities only throws when isObject(...) && length === 0. A caller passing a truthy non-plain-object - most likely real misconfiguration: routeCapabilities: ['GET /sites', ...] - passes both this guard and the if (!routeCapabilities) throw. In the wrapper body, if (isObject(routeCapabilities)) evaluates false and the entire auth block is skipped. RO admins bypass all authorization.

Fix: tighten the guard to require a non-empty plain object. Throw on arrays, strings, numbers. The same shape exists in s2sAuthWrapper, so this improves both wrappers. Add a unit test for routeCapabilities: [].

3. spaceCatId alias couples shared utils to spacecat-api-service routing conventions (read-only-admin-wrapper.js:65-66)

spaceCatId is a route-naming convention owned by spacecat-api-service (/v2/orgs/:spaceCatId/*). The shared HTTP-utils package now hardcodes that knowledge. A future consumer that uses :spaceCatId for a different resource - or a future API-service refactor that retires the alias - gets silently wrong authorization decisions because this wrapper unconditionally treats :spaceCatId as an organization ID.

Fix: make the alias configurable per wrapper instance, e.g. opts.paramAliases: { spaceCatId: 'organizationId' }. Default {}; spacecat-api-service supplies the alias at its call site.

4. internalRoutes introduces a parallel source of truth with drift risk (read-only-admin-wrapper.js:207)

Consumers must keep routeCapabilities (capability mapping) and internalRoutes (param extraction only) in sync. Forgetting internalRoutes is a silent denial; forgetting both is the Critical bypass above.

Cleanest fix (combined with the Critical fix): collapse to a single map. Allow routeCapabilities: { 'PUT /sites/:siteId': null } to mean "known route, no capability, deny" or 'site:owner-only' to mean "ownership gates". Update resolveRouteCapability to distinguish explicit null from "not in map" via hasOwnProperty. Then extractRouteParams doesn't need a fallbackRoutes parameter at all.

5. Parent-child resource ambiguity for nested path params (read-only-admin-wrapper.js:52-100)

For PATCH /sites/:siteId/audits/:auditId, the wrapper authorizes on siteId alone. If audit-x belongs to site-b but Mallory sends PATCH /sites/mallory-site/audits/audit-x, the wrapper allows because Mallory owns mallory-site. If the handler doesn't verify audit-x.siteId === 'mallory-site', Mallory mutates an audit in another org via her own site's URL prefix.

Same risk class as the body-fallback contract documented in JSDoc, but not documented for nested path params.

Fix: document the parent-child path-param contract ("Handlers behind path-param authorization MUST verify all nested resource IDs belong to the authorized parent"), or stop authorizing on the parent alone when multiple resource IDs are present.

6. Feature flag scoped to getTenantIds()[0] only (read-only-admin-wrapper.js:129-130)

For multi-org users, only the first tenant ID is checked. A user in orgs [A, B] where FT_READ_ONLY_ORG is enabled for A but not B proceeds past the feature flag, then can access B-owned resources via hasOrganization(B) returning true. Effectively: an RO admin in an enrolled org can act on resources in a non-enrolled org they also belong to.

Decide and document the semantics: is the flag (a) "user-org gate" (check primary tenant, current behavior) or (b) "resource-org gate" (check after ownership lookup against the target org)? Add a test pinning the multi-tenant ordering behavior.

Minor (Nice to Have)

1. Silent failure when dataAccess.Site / dataAccess.Organization accessor is missing (read-only-admin-wrapper.js:73, 87)

Optional chaining returns undefined when dataAccess.Site is absent; !site then returns false with no log. The behavior is fail-closed (good), but operationally indistinguishable from "site not found". Add log.error({ tag: 'ro-admin' }, 'dataAccess.Site accessor missing') before returning false. The test at line 791 asserts 403 but no log.

2. JSDoc does not document siteId-over-organizationId precedence (read-only-admin-wrapper.js:22-50)

isOwnerOfResource checks siteId first; if a route surfaces both, organizationId is dead code. Add to JSDoc: "When both siteId and organizationId are resolvable, siteId is preferred."

3. err logged with full stack in production paths (read-only-admin-wrapper.js:96, 137, 256)

log.error({ err }) typically serializes err.message + err.stack. DB driver stack frames can include table names, region, and connection identifiers. Consider passing { message: err.message, name: err.name } at error level and gating full error to debug, or confirm the central log pipeline strips stacks.

4. Two coverage refinements for the new logging contract (read-only-admin-wrapper.test.js)

The body-fallback access-log test asserts the access log is emitted but doesn't assert the audit log is NOT emitted (the path-based equivalent does both). Add the suppression assertion. Separately, resolvedOrgId is logged but never asserted in any access-log test.

5. organizationId precedence over spaceCatId not pinned by test

params.organizationId ?? params.spaceCatId and the body equivalent: ?? means organizationId wins when both are set. Add context.data = { organizationId: 'org-A', spaceCatId: 'org-B' } -> assert findById('org-A') is called.

Recommendations

  • File a follow-up to extract isOwner as a pluggable strategy. The wrapper hardcodes two ownership chains; extending to a third resource type (project, brand, opportunity, scrape job) means editing this shared library every time. An opts.isOwner: async (context, authInfo, params) => boolean shape lets consumers contribute resolvers without touching the security choke point.
  • Cross-domain consistency with s2sAuthWrapper: both consume routeCapabilities. After this PR, RO admin allows unmapped routes by ownership while s2s denies. Intentional given different threat models, but add a one-liner in each JSDoc pointing at the sibling wrapper so future readers see them as a pair with deliberately different policies.
  • Performance note in JSDoc: every RO admin write adds a DB round-trip (Site.findById + getOrganization() is two; Organization.findById is one). If Site carries imsOrgId directly, inline it: site.getImsOrgId() skips the org round-trip.
  • Consider a startup-time invariant check: cross-reference routes mounted under this wrapper against routeCapabilities. Turns "consumer forgot to enumerate" from a runtime exposure into a deploy-time failure.
  • The audit log semantic change (was ro-admin-audit on every allowed; now split between ro-admin-access for owned-writes and ro-admin-audit for reads) is a quiet contract break for downstream dashboards. Worth a callout in the PR description and a notification to dashboard owners.

Out of scope, worth tracking

  • Pluggable isOwner strategy for new resource types.
  • Mount-time invariant check of routes vs. routeCapabilities.
  • Resource-org vs user-org feature flag scoping (related to Important item 6).
  • Configurable paramAliases (related to Important item 3).

Assessment

Ready to merge? No - with fixes.

Reasoning: The mapped-route happy paths are well-engineered and the fail-closed posture is consistently applied. The Critical bypass is a confused-deputy pattern reachable when a consumer adds a new resource-path route and forgets either route list - a security wrapper should fail closed on this class of mistake, not rely on a post-hoc log. The cleanest fix (which also addresses Important items 1 and 4 in one change): deny when capability === null regardless of ownership, and let internalRoutes go away. The other Important items are contract clarifications and config-robustness improvements that should land before this becomes load-bearing across more consumers.

Next Steps

  1. Address the Critical bypass and Important items 1 and 4 together via the recommended single change (deny on capability=null; collapse internalRoutes into routeCapabilities).
  2. Tighten the routeCapabilities config-shape guard (Important item 2).
  3. Decide and document the feature-flag tenant scoping semantics (Important item 6).
  4. Document or enforce the parent-child path-param contract (Important item 5).
  5. Make spaceCatId alias configurable (Important item 3).
  6. Minor items can land in a polish commit.

// When params has keys, the route does have path identifiers; relying on body data in that
// case could allow a caller to spoof ownership by naming params differently than :siteId.
const hasPathParams = Object.keys(params).length > 0;
const siteId = hasPathParams ? params.siteId : context.data?.siteId;
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.

Critical: confused-deputy / IDOR bypass for unmapped path-resource routes.

The hasPathParams === false gate relies on the wrapper's own route lists (routeCapabilities + internalRoutes) being exhaustive. When a write route lives in NEITHER list but its URL contains a resource segment, extractRouteParams returns {} (no match anywhere), hasPathParams is false, the body fallback consults context.data, and the wrapper authorizes against the body-claimed resource while the handler may operate on the URL-claimed resource.

Attack: developer adds PUT /sites/:siteId/transfer-ownership and forgets both lists. Mallory (RO admin) owns mallory-site and sends PUT /sites/victim-site/transfer-ownership with body {"siteId":"mallory-site"}. Wrapper authorizes against mallory-site. Handler reads its own context.params.siteId = 'victim-site' from the framework's URL pattern and mutates the victim site in another org. The drift log.warn fires but does NOT block.

Fix (recommended): only allow body fallback when capability !== null. Preserves legitimate POST /preflight/jobs (mapped, no path params, body siteId IS the resource) while denying ANY unmapped route. Add a test asserting PUT /sites/<victim>/<unmapped> with body {siteId: '<owned>'} returns 403 and that Site.findById is NOT called with the owned body siteId.

Alternative: have extractRouteParams return null for no-match vs {} for matched-with-no-params, and fail-closed in the wrapper on params === null.

// resource ID (no path param and no body siteId/organizationId) fail-closed.
const isOwner = await isOwnerOfResource(context, authInfo, params);
if (isOwner) {
if (capability === null) {
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: permissive default for unmapped writes erodes the explicit-allow-list model.

The "ownership-first" choice (capability=null + isOwner=true ALLOWED with a drift warn) inverts the safe default for a security wrapper. Owning a site is not a generalized authorization claim for every operation defined on that site. RO admin is bounded by FT_READ_ONLY_ORG and capability mapping precisely so that adding sensitive operations requires deliberate policy.

A new destructive endpoint added without a routeCapabilities entry is callable by any RO admin who owns the target, with only log.warn to flag it. The drift signal is reactive; the gate is missing.

Recommended fix (addresses the Critical above + the internalRoutes drift item together): when capability === null, deny unmapped writes regardless of ownership. The "RO admin can write resources their org owns" property is then expressed by explicitly mapping the route in routeCapabilities. internalRoutes becomes unnecessary - consumers add new routes to one map only.

If you keep the permissive default, please elevate the drift signal to an alert or fail-closed in non-prod environments.

* no match (e.g. 'DELETE /sites/:siteId')
* @returns {Object<string, string>}
*/
export function extractRouteParams(context, routeMap, fallbackRoutes = []) {
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: routeCapabilities non-object input silently fails open.

The construction-time guards are if (!routeCapabilities) throw plus guardNonEmptyRouteCapabilities, which only throws when isObject(...) && length === 0. A caller passing a truthy non-plain-object - most likely real misconfiguration: routeCapabilities: ['GET /sites', ...] - passes both guards. Then in the wrapper body if (isObject(routeCapabilities)) evaluates false and the entire auth block is skipped, so RO admins bypass all authorization.

Fix: tighten this guard to require a non-empty plain object. Throw on arrays, strings, numbers - anything that is not a plain object with at least one key.

export function guardNonEmptyRouteCapabilities(wrapperName, routeCapabilities) {
  if (!isObject(routeCapabilities) || Object.keys(routeCapabilities).length === 0) {
    throw new Error(`${wrapperName}: routeCapabilities must be a non-empty object`);
  }
}

The same guard is used by s2sAuthWrapper, so tightening it is defense-in-depth for both wrappers. Add a unit test for routeCapabilities: [].

const hasPathParams = Object.keys(params).length > 0;
const siteId = hasPathParams ? params.siteId : context.data?.siteId;
const organizationId = hasPathParams
? (params.organizationId ?? params.spaceCatId)
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: spaceCatId alias couples shared utils to spacecat-api-service routing conventions.

spaceCatId is a route-naming convention owned by spacecat-api-service (used by /v2/orgs/:spaceCatId/*). The shared HTTP-utils package now hardcodes that knowledge in both the path-param branch and the body branch.

A future consumer of this wrapper that uses :spaceCatId for a different resource type (or a future API-service refactor that retires the alias) gets silently wrong authorization decisions because this wrapper unconditionally treats :spaceCatId as an organization ID.

Fix: make the alias configurable per wrapper instance. Default {}; spacecat-api-service supplies the alias at its call site.

// In opts:
paramAliases: { spaceCatId: 'organizationId' }

// In isOwnerOfResource:
const organizationId = hasPathParams
  ? (params.organizationId ?? resolveAlias(params, paramAliases, 'organizationId'))
  : (context.data?.organizationId ?? resolveAlias(context.data, paramAliases, 'organizationId'));

Pushes the convention back to the consumer; keeps the wrapper neutral.

tag: 'ro-admin',
let accessLogged = false;
try {
const params = extractRouteParams(context, routeCapabilities, internalRoutes);
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: internalRoutes introduces a parallel source of truth with drift risk.

There are now two collections of route patterns consumers must keep in sync: routeCapabilities (capability mapping) and internalRoutes (param extraction only). Adding a new PUT /sites/:siteId requires the author to remember both. Forgetting internalRoutes is a silent denial; forgetting both is the Critical bypass.

Cleanest fix (combined with the Critical fix above): collapse to a single map. Allow routeCapabilities: { 'PUT /sites/:siteId': null } to mean "known route, no capability required, deny" or use a sentinel like 'site:owner-only'. Update resolveRouteCapability to distinguish explicit null from "not in map" via hasOwnProperty rather than truthy check (route-utils.js:60). Then extractRouteParams does not need a fallbackRoutes parameter at all - it walks the same map.

Less surface, no drift, and the Critical bypass disappears because every guarded route is enumerated.

* @param {Object<string, string>} params - Named path params extracted from the route pattern
* @returns {Promise<boolean>}
*/
async function isOwnerOfResource(context, authInfo, params) {
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: parent-child resource ambiguity for nested path params.

For a route like PATCH /sites/:siteId/audits/:auditId, the wrapper authorizes on siteId alone. If audit-x actually belongs to site-b, but Mallory sends PATCH /sites/mallory-site/audits/audit-x, the wrapper:

  • extracts { siteId: 'mallory-site', auditId: 'audit-x' }
  • looks up mallory-site -> Mallory's org -> hasOrganization returns true
  • allows the request

The handler may or may not verify that audit-x.siteId === 'mallory-site'. If it does not, Mallory mutates an audit in another org via her own site's URL prefix.

This is materially different from the body-fallback contract that JSDoc documents ("Handlers behind body-fallback authorization MUST only mutate the declared siteId/organizationId resource"). The same risk applies to path params when multiple resource IDs are present, but it is not documented and not enforced.

Fix: pick one of

  • Document explicitly in the wrapper JSDoc: "Handlers behind path-param authorization MUST verify all nested resource IDs belong to the authorized parent." Make this part of the public contract.
  • Stop authorizing on the parent alone when multiple resource IDs are present - require an explicit child-level check (heavier but removes the implicit-handler-contract footgun).

* @returns {Promise<boolean>}
*/
async function evaluateFeatureFlag(context, authInfo) {
const { log } = 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.

Important: feature flag scoped to getTenantIds()[0] only.

For multi-org users, only the first tenant ID is checked. Behavior is order-dependent: a user in orgs [A, B] is treated differently than the same user in [B, A]. If FT_READ_ONLY_ORG is enabled for A but not B, the user in [A, B] proceeds past the feature flag, and can then access B-owned resources via hasOrganization(B) returning true.

Effectively: an RO admin in an enrolled org can act on resources in a non-enrolled org they also belong to.

Decide and document the semantics:

  • (a) "user-org gate": check the user's primary tenant (current behavior - defensible but should be explicit).
  • (b) "resource-org gate": check the flag against the resource's owning IMS org AFTER the ownership lookup (closer to per-tenant rollout semantics).

Also: tenantIds[0] reflects whatever order the JWT issuer puts tenants in. Document the dependency, or evaluate the flag for all tenants the user has (any-true / all-true depending on intent).

Add a test pinning the multi-tenant ordering behavior so the choice does not silently flip later.

@ravverma ravverma merged commit e0e6783 into main May 12, 2026
5 checks passed
@ravverma ravverma deleted the feat/ro-admin-owned-resource-write branch May 12, 2026 04:59
solaris007 pushed a commit that referenced this pull request May 12, 2026
## [@adobe/spacecat-shared-http-utils-v1.27.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.26.1...@adobe/spacecat-shared-http-utils-v1.27.0) (2026-05-12)

### Features

* **http-utils:** allow RO admin write ops on resources they own ([#1595](#1595)) ([e0e6783](e0e6783))
@solaris007
Copy link
Copy Markdown
Member

🎉 This PR is included in version @adobe/spacecat-shared-http-utils-v1.27.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@ravverma
Copy link
Copy Markdown
Contributor Author

@solaris007 Sorry I have merged it accidentally, let me fix the issue next PR

ravverma added a commit that referenced this pull request May 12, 2026
## Summary

Follow-up to [PR
#1595](#1595) addressing
the Critical / Important / Minor findings from @solaris007's
[review](#1595 (review)).

## Changes

### Critical (defense-in-depth adopted)

The reviewer's attack scenario premise - "developer adds a new route and
forgets BOTH `routeCapabilities` AND `internalRoutes`" - is prevented by
an existing in-place test in `spacecat-api-service` that asserts every
mounted route is enumerated in one of the two maps. The attack is
therefore not reachable in practice.

However, the reviewer's recommended fix is a strict improvement to the
wrapper's contract regardless of the api-service test, so it has been
adopted as defense-in-depth:

- **Body fallback now requires an explicit capability mapping.** When
`capability === null` AND no path params are resolvable (route is in
NEITHER `routeCapabilities` NOR `internalRoutes`), the wrapper denies
up-front with `reason: 'unmapped-no-path-params'`. The body's
siteId/orgId claim is no longer consulted in that case.
- Legitimate body-fallback routes (e.g. `POST /preflight/jobs` with body
siteId) are unaffected because they have an explicit capability mapping
(`'POST /preflight/jobs': 'preflight:write'`).
- The wrapper is now safe to drop into a consumer that does not have
route-exhaustiveness tests.

### Important

1. **Permissive default for unmapped writes** - The ownership-first
model is preserved for routes in `internalRoutes` (path-params
resolvable). The Critical fix above closes the body-fallback escape
hatch. `unmapped-route-allowed` drift warn still fires so consumers can
detect routeCapabilities drift.
2. **`routeCapabilities` non-object input silently fails open** -
`guardNonEmptyRouteCapabilities` tightened to require a non-empty plain
object. Rejects undefined, null, arrays, strings, numbers.
`s2sAuthWrapper` inherits this guard.
3. **`spaceCatId` alias couples shared utils to api-service** - Added
`paramAliases` option (default `{}`). Consumers declare `{ spaceCatId:
'organizationId' }` at the call site. The wrapper no longer hardcodes a
service-specific routing convention.
4. **`internalRoutes` parallel source of truth** - Kept as designed. The
api-service test prevents drift; collapsing to a single map would
require a sentinel value scheme that adds its own complexity. Documented
the relationship in JSDoc.
5. **Parent-child resource ambiguity** - Documented explicit handler
contract in JSDoc: handlers behind path-param authorization MUST verify
nested resource ids belong to the authorized parent before mutating
them.
6. **Feature flag scoped to `tenantIds[0]`** - Documented the "user-org
gate" semantics. Added a test pinning multi-tenant ordering behavior so
the choice cannot silently flip later.

### Minor

1. **Silent failure on missing accessor** - `dataAccess.Site` /
`dataAccess.Organization` absence now emits `log.error`.
2. **siteId-over-organizationId precedence** - Documented in
`isOwnerOfResource` JSDoc.
3. **Stack-trace leakage** - Error logs now use `{ errMessage:
err.message, errName: err.name }` instead of `{ err }` to avoid DB
driver internals (table names, region, connection ids) leaking through
the structured logger.
4. **Coverage refinements** - Body-fallback access-log test asserts
`ro-admin-audit` is NOT emitted (suppression contract) and asserts
`resolvedOrgId`. New test for org-route access log with `resolvedOrgId`.
5. **organizationId precedence over spaceCatId not pinned** - Added two
tests: canonical wins over alias when both are set in body; spaceCatId
is ignored when `paramAliases` is not configured.

### Recommendations addressed

- Cross-domain consistency note: JSDoc cross-references `s2sAuthWrapper`
as a deliberately-different-policy sibling.
- Performance note: JSDoc documents per-write DB round-trip cost.
- FF-disabled denial now includes `reason: 'feature-flag-disabled'`
field for SIEM filterability (also covers a prior Minor).

### Recommendations not adopted in this PR

- **Pluggable `isOwner` strategy** - Larger refactor; worth its own PR.
- **Startup-time invariant check of mounted routes vs
`routeCapabilities`** - Already enforced at the consumer (api-service)
level; not adding a duplicate check to the shared wrapper.

## Test plan

- [x] 344 tests passing, 100% lines / statements coverage, 97.64%
branches (threshold 97%).
- [x] Lint clean.
- [ ] Verify api-service tests still pass against this version (test the
wrapper with the existing routeCapabilities + internalRoutes maps; add
`paramAliases: { spaceCatId: 'organizationId' }` at the call site).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants