feat(http-utils): allow RO admin write ops on resources they own#1595
Conversation
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>
|
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>
|
Self reviewed with review kit All three agents completed. Here's the consolidated review: PR #1595 Review SummaryCritical (fix before merge)[security] Body fallback activates on param name mismatch, not just "no params in route"
Fix: only fall back to 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] [observability] Missing [observability] No-ID-found case is silent and indistinguishable from ownership failure [audit] Audit log omits resolved resource ID and whether body fallback was used [reliability] Suggestions (nice to have)[test] [test] [test] [docs] JSDoc should document that Strengths
|
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>
|
Pushed. Here's a summary of everything addressed from the review: Critical (security)
Observability / logging (4 gaps fixed)
Reliability
New tests (9 added)
|
…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>
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>
|
Hey @ravverma, Thanks for the careful design and the detailed self-review trail. The fail-closed posture is solid, body-fallback bypass via Strengths
IssuesImportant (Should Fix)
Minor (Nice to Have)
Recommendations
AssessmentReady 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, Next Steps
|
- 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>
|
Hey @ravverma, Re-review on commit StrengthsPreviously flagged issues now resolved:
IssuesImportant (Should Fix)
Minor (Nice to Have)
Recommendations
AssessmentReady 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
|
…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>
|
Hey @ravverma, Re-review on commit StrengthsPreviously flagged issue now resolved:
IssuesMinor (Nice to Have)
Recommendations
The previously raised minor items from the last re-review (denial-reason wording, FF-disabled AssessmentReady 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 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>
solaris007
left a comment
There was a problem hiding this comment.
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, missingSite/Organizationaccessor, null entity, nullimsOrgId, 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 === falsegate closes the param-name spoofing attack for routes that ARE inrouteCapabilitieswith non-:siteIdparam names. The test at read-only-admin-wrapper.test.js:786 is the right test to have. - The
imsOrgId == nullguard beforehasOrganization(...)prevents the TypeError thatauth-info.jswould throw onundefined.split('@'). - Read fast-path skips the DB lookup for
read/readAllcapabilities. - Drift-detection
unmapped-route-allowedwarn gives operators an actionable signal (though see Important item 1 for why detection alone is not enough here). - The
accessLoggedflag cleanly prevents double audit-log emission. - Wrapper-ordering requirements (
dataAccessWrapperand 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:
- A developer adds
PUT /sites/:siteId/transfer-ownershipto spacecat-api-service. They forget to add it to eitherrouteCapabilitiesorinternalRoutes. - Mallory (RO admin) owns site
mallory-site. She wants to attackvictim-sitein another IMS org. - Mallory sends
PUT /sites/victim-site/transfer-ownershipwith body{"siteId":"mallory-site"}. extractRouteParamsreturns{}(route in neither list);hasPathParams = false.- Body fallback:
siteId = 'mallory-site'.Site.findById('mallory-site')resolves to Mallory's org.hasOrganization(...)returns true. capability === null-> driftlog.warnfires;ro-admin-accesslog recordsidSource: 'body',resolvedSiteId: 'mallory-site'.- Wrapper calls
fn(request, context). The handler reads its owncontext.params.siteIdfrom 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
isOwneras 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. Anopts.isOwner: async (context, authInfo, params) => booleanshape lets consumers contribute resolvers without touching the security choke point. - Cross-domain consistency with
s2sAuthWrapper: both consumerouteCapabilities. 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.findByIdis one). IfSitecarriesimsOrgIddirectly, 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-auditon every allowed; now split betweenro-admin-accessfor owned-writes andro-admin-auditfor 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
isOwnerstrategy 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
- Address the Critical bypass and Important items 1 and 4 together via the recommended single change (deny on capability=null; collapse internalRoutes into routeCapabilities).
- Tighten the routeCapabilities config-shape guard (Important item 2).
- Decide and document the feature-flag tenant scoping semantics (Important item 6).
- Document or enforce the parent-child path-param contract (Important item 5).
- Make
spaceCatIdalias configurable (Important item 3). - 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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 = []) { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 ->hasOrganizationreturns 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; |
There was a problem hiding this comment.
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.
## [@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))
|
🎉 This PR is included in version @adobe/spacecat-shared-http-utils-v1.27.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
|
@solaris007 Sorry I have merged it accidentally, let me fix the issue next PR |
## 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>
Problem
The
readOnlyAdminWrapperpreviously 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— newextractRouteParamsAdded a focused, standalone
extractRouteParams(context, routeMap)function that returns the named path parameters extracted from the matched route pattern (e.g.{ siteId: 'abc-123' }fromPATCH /sites/:siteId). The existingresolveRouteCapabilityis left completely unchanged so the s2s wrapper is unaffected.read-only-admin-wrapper.js— ownership check before blocking writesFor write actions, before returning 403 the wrapper now calls
isOwnerOfResource(context, authInfo, params):siteIdis in the path params: fetchescontext.dataAccess.Site.findById(siteId), resolves the org, and callsauthInfo.hasOrganization(org.getImsOrgId())organizationIdis in the path params: fetchescontext.dataAccess.Organization.findById(organizationId)and checks the samedataAccessabsent, entity not found, no recognisable ID in the path, or any DB exception — the exception is logged vialog.errorand the request is blockedRead paths are completely untouched.
context.datafallback for ID-less routesSome write routes carry no resource ID in the path but pass it in the request body instead (e.g.
POST /preflight/jobssendssiteIdin the JSON payload, resolved viacontext.data.siteId). For these casesisOwnerOfResourcefalls back tocontext.dataafter finding no match in the route params:Path params always take precedence over body data when both are present. Routes that carry neither (e.g.
POST /sitescreating a new resource) still return false and are blocked.Test coverage
route-utils.test.js— 8 new cases forextractRouteParams: 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":context.data(e.g.POST /sites) → 403dataAccesson context → 403 (fail-closed)log.errorcalled with the error (fail-closed)context.data.siteId(no path param) → allowedcontext.data.siteId→ 403context.data.organizationId(no path param) → allowedcontext.datawhen both presentAll 309 tests pass. Coverage: 100% lines/statements, 98.18% branches (threshold: 97%).
Checklist
resolveRouteCapabilityuntouched — s2s wrapper unaffectedcontext.datafallback handles routes likePOST /preflight/jobswith no ID in path🤖 Generated with Claude Code