feat(http-utils): allow RO admin write ops on resources they own#1595
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)
|
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