feat(security): per-endpoint read/write scope classification + full service map (#3102)#3103
feat(security): per-endpoint read/write scope classification + full service map (#3102)#3103Ben-Heerema wants to merge 13 commits into
Conversation
OAuth 1.0a access tokens persisted the scopes a provider approved on the consent screen, but nothing ever read them: any valid token granted the provider's full privileges across the entire /ws/services/* surface, and /initiate accepted arbitrary scope strings. This adds config-gated scope enforcement (default OFF, non-breaking): - OAuthScopes: a pure, side-effect-free vocabulary + matching utility. Coarse <domain>.read / <domain>.write model with write-implies-read, piloted on the schedule and tickler domains; every other endpoint maps to NO_SCOPE_REQUIRED so enabling enforcement does not break the un-piloted surface. - OAuthInterceptor: when oauth.scope.enforcement.enabled is truthy, resolve the request's required scope from method + URI and reject tokens whose granted scopes do not satisfy it with HTTP 403 insufficient_scope. The check runs before LoggedInInfo is attached and a config-read failure fails open (enforcement stays off) to avoid a blanket API outage. - OscarRequestTokenService: when enabled, /initiate rejects empty/unknown scopes with HTTP 400 invalid_scope instead of persisting them. Behaviour is unchanged unless the flag is set. Extending the pilot to the remaining services is tracked as follow-up to #3083. Tests: OAuthScopesUnitTest (vocabulary/resolution/matching), OAuthInterceptorScopeEnforcementUnitTest (403 out-of-scope, in-scope admit, flag-off passthrough), OscarRequestTokenServiceScopeValidationUnitTest (400 reject empty/unknown, accept known, flag-off lenient). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
Addresses review findings on the OAuth 1.0a scope-enforcement PR:
- Scope bypass (CodeRabbit/gemini/cubic): OAuthScopes.pathRootUnderServices
now percent-decodes and strips matrix parameters before matching the
domain root, so requests like /ws/services/%73chedule/... or
/ws/services/schedule;x=1/... can no longer resolve to NO_SCOPE_REQUIRED
while still routing to the schedule resource. Decoding precedes matrix
stripping so an encoded ';' (%3B) is handled too; resolution errs toward
enforcement, never toward bypass.
- Leading whitespace in scopesCsv: /initiate now trims before split("\\s+")
so a valid scope with surrounding whitespace is no longer rejected as
invalid_scope under enforcement.
- IMPROPER_UNICODE (SpotBugs): replace locale-sensitive toLowerCase/
toUpperCase with a fixed ASCII fold, matching the existing OAuth helpers
in this package; no behaviour change for ASCII scopes/methods.
- Reliability nit: compare requiredScope with == null instead of the
null-valued NO_SCOPE_REQUIRED constant (clears the String-== smell).
- Test isolation: both scope test classes now capture and restore the
enforcement flag on the shared CarlosProperties singleton instead of
unconditionally removing it.
Tests: add percent-encoded and matrix-parameter resolution cases to
OAuthScopesUnitTest. Full scope suite: 24 tests, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
…3083) Second round of review findings on the OAuth scope-enforcement PR: - Dot-segment bypass (cubic P1): OAuthScopes.pathRootUnderServices now collapses '.' and '..' path segments (in addition to the existing percent-decode + matrix-parameter stripping) before matching the domain root, mirroring container/JAX-RS path resolution. A request such as /ws/services/./schedule/... or /ws/services/other/../schedule/... can no longer resolve to NO_SCOPE_REQUIRED while still routing to the schedule resource. A Deque is used so '..' correctly pops a prior domain segment. - Null persisted scopes -> 500 (cubic P2): OscarOAuthDataProvider.getAccessToken guarded sat.getScopes() against null/blank so a valid token with no persisted scopes yields an empty grant (enforcement fails closed with 403) instead of NPE-ing into a 500. getAccessToken's only caller is the new enforcement path. - Tests: capture the enforcement flag with the nullable getProperty(key, null) in both scope test classes (CodeRabbit); add verify(never).createRequestToken to the no-scope rejection test (cubic P3). Tests added: '.'/'..' resolution cases (OAuthScopesUnitTest), 403 on a token with no granted scopes (OAuthInterceptorScopeEnforcementUnitTest), and null-persisted-scope handling (OscarOAuthDataProviderUnitTest). Full affected suite: 54 tests, 0 failures; SonarQube quality gate now passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
…ment (#3083) cubic flagged a possible scope-enforcement bypass via a leading '..' segment after the /ws/services marker. Verified against the current implementation: pathRootUnderServices already normalizes dot-segments with a Deque, so a leading '..' pops nothing and the following pilot domain is still selected (the request is enforced, not treated as unpiloted). No production change is needed. Adds a regression test asserting that '/ws/services/../schedule/...' and '/ws/services/../../tickler/...' resolve to schedule.read / tickler.write respectively, locking in the no-bypass behaviour cubic asked to cover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
…tion (#3083) CodeRabbit flagged that percentDecode can reveal a separator inside a single raw path segment: /ws/services/%2e%2e%2fschedule/... decodes to a root of '../schedule', and /ws/services/schedule%2Fday hides the 'schedule' root, both falling back to NO_SCOPE_REQUIRED while a permissive container may still route to the schedule resource — a scope-enforcement bypass. OAuthScopes.pathRootUnderServices now re-splits each decoded segment on '/' before stripping matrix parameters and collapsing dot-segments, so an encoded slash (%2F) is treated as a path boundary. Resolution continues to err toward enforcement, never bypass. Adds a regression test asserting /ws/services/schedule%2Fday and /ws/services/%2e%2e%2fschedule/day both resolve to schedule.read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
) cubic flagged that pathRootUnderServices matched the first '/services/' rather than the documented '/ws/services/' mount, so a URI carrying an unrelated earlier 'services' segment could misanchor the root and make a piloted endpoint look unpiloted. The CXF data API is mounted at /ws/services/ (servlet /ws/* + JAX-RS address /services), so anchoring the marker to the full /ws/services/ prefix is both correct and unambiguous. All real request URIs contain that prefix, so behaviour for valid requests is unchanged. Adds a regression test asserting that a decoy earlier '/services/' segment does not misanchor: /services/decoy/ws/services/schedule/day resolves to schedule.read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
cubic flagged that pathRootUnderServices ran indexOf("/ws/services/") on the raw
(undecoded) request URI, so an encoded mount prefix such as /ws/%73ervices/...
(where %73 = 's', which the container decodes and routes to /ws/services/) was
missed, fell back to NO_SCOPE_REQUIRED, and bypassed enforcement while still
reaching the schedule resource.
Reworks the resolver to strip the query string and then percent-decode the whole
path once (matching the container's single-pass decode) before locating the
/ws/services/ marker. This also subsumes the prior per-segment decode/re-split:
an encoded slash or encoded dot-segment in the path is now a real boundary after
the single up-front decode. Resolution still errs toward enforcement, never
bypass, and all previously covered cases (matrix params, dot-segments, encoded
slash, decoy earlier 'services', anchored mount) keep their behaviour.
Adds a regression test for an encoded mount prefix: /ws/%73ervices/schedule/...
and /ws%2Fservices/tickler/... resolve to schedule.read / tickler.write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
… URI (#3083) Resolve the scope domain from HttpServletRequest.getPathInfo() — the path the servlet container has already URL-decoded and canonicalized (dot-segments collapsed, matrix/path parameters stripped) and that JAX-RS/CXF route on — instead of re-parsing the raw getRequestURI(). This is strictly more correct (it reads the exact path the request is routed by) and removes the hand-rolled percent-decode / matrix-strip / dot-segment-collapse machinery that accreted over successive review rounds, each guarding a theoretical encoding case. Verified live on the CARLOS stack (Tomcat 11.0.22 + CXF 4.1.5): a PRE_INVOKE interceptor retrieving the request via AbstractHTTPDestination.HTTP_REQUEST gets the raw container request (org.apache.catalina.connector.RequestFacade), whose getPathInfo() is /services/<domain>/... for every routed request regardless of how the client percent-encoded the URI; matrix params and ./.. are already resolved, and an encoded slash (%2F) is rejected by the container (HTTP 400) before the interceptor runs. So the prior URI normalization was defending inputs that either never reach the resource or are container-rejected. OAuthScopes.pathRootUnderServices is now a simple "first segment after /services/" lookup over the canonical path; OAuthInterceptor passes req.getPathInfo(). Behaviour for real requests is unchanged. Net -106 lines. Tests updated to feed canonical servlet path-info inputs; the interceptor test sets pathInfo on the mock request. Full OAuth suite: 62 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
Addresses review nit: the "null/blank method" test only asserted null. Add assertions for "" and " " so the documented blank-method-is-write behaviour is actually verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
…ervice map (#3102) Follow-up to #3083/#3094. Extends OAuthScopes from the schedule/tickler pilot to the whole /ws/services surface and replaces the method-only read/write heuristic with per-endpoint classification. - DOMAIN_BY_PATH_ROOT now maps every JAX-RS service root under /ws/services to a scope domain (~33 roots → ~28 domains; rx+rxlookup→rx, reporting+reportbytemplate →report, eform+eforms→eform, demographics[/merge]→demographic). The /initiate vocabulary (KNOWN_SCOPES) is derived from the map, so all domains' .read/.write are now accepted. - Read/write is classified per endpoint, not by HTTP method alone: safe methods are reads; non-safe methods are writes EXCEPT (a) roots whose entire non-safe surface is read-only (NONSAFE_READ_ROOTS: measurements, recordUX, rxlookup, patientDetailStatusService), and (b) read-only POSTs identified by a path segment marker per root (READ_OP_SEGMENTS_BY_ROOT: e.g. tickler/search, schedule/getAppointment, notes/.../all, demographics/search, consults/search*, reporting/getReport). This fixes the cubic P2 from #3094 where a read-only POST required a .write grant. - Classification was built by reading every service's @GET/@POST/@PUT/@delete methods. Resolution still keys off the container-canonical getPathInfo() and is unchanged in mechanism; only the domain/read-write tables grew. Enforcement stays gated by oauth.scope.enforcement.enabled (default off), so the map can be reviewed/corrected before it is ever enabled. Tests: extend OAuthScopesUnitTest with the full-map cases, shared-domain roots, read-only-POST overrides (tickler/search → tickler.read, etc.), wholly-read-only services, mutating-POST writes, and the expanded /initiate vocabulary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
This comment has been minimized.
This comment has been minimized.
|
@coderabbitai review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ey (#3102) The DiseaseRegistryService is mounted at @path("/dxRegisty") (the service's own annotation is misspelled), so the scope map key must be "dxregisty" to match the routed getPathInfo(). Adds a comment so the typo isn't "corrected" to "dxregistry", which would silently make the disease-registry endpoints resolve to NO_SCOPE_REQUIRED. Addresses a false-positive raised by gemini-code-assist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
|
@coderabbitai review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…alue escalation (#3102) Addresses the cubic re-review of the per-endpoint classification: - P1 (privilege escalation): read classification scanned ALL path segments for read-operation marker words, including path-PARAMETER values — so a mutating endpoint whose string param happened to equal a marker (e.g. POST /providerService/settings/{providerNo=search}/save) could be classified as a read and authorized by a .read scope. Replaced the flat marker set with explicit per-root path TEMPLATES matched positionally (exact length; "*" wildcards for parameter segments), so a parameter value can never trigger a false read. Also gated the read override to POST: every read-only non-safe operation in CARLOS is a POST, so a PUT/DELETE to a matching path is now always a write. - P2 (fail-safe): a null/blank (or non-POST) method no longer slips through the read path on wholly read-only roots — it falls through to write. Net effect: reads still classify as reads (tickler/search, schedule/getAppointment, notes/{id}/all, providerService/providers/search, …), but DELETE /demographics/search, POST /providerService/settings/search/save, and null/blank-method requests are writes. Tests: add coverage for param-value-equals-marker (stays write), DELETE on a read-marker path (write), and non-POST/missing methods on read-only services (write). Full scope suite: 31 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
|
@coderabbitai review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Note for future self: 3094 should merge before this! |
…s a safe method (#3083) Addresses review findings surfaced on the stacked PR #3103: - Double token lookup (CodeRabbit): the interceptor resolved the provider via getProviderNoByAccessToken and then loaded the token AGAIN via getAccessToken for scope checks — two reads that could race expiry/revocation (turning an auth failure into a 403) plus an extra DAO hit on every enforced request. Now it loads once via a newly-exposed findUnexpiredAccessToken and reads both the provider (getProviderNo) and the granted scopes (getScopes) off that single ServiceAccessToken. The now-unused getAccessToken is removed; getProviderNoByAccessToken stays (still used by the signature verifier). Null/blank persisted scopes still fail closed (403), and provider resolution cost is unchanged (no client lookup). - isSafeMethod now also treats TRACE as a safe (read) method per RFC 7231, for completeness (the container normally rejects TRACE and no JAX-RS resource handles it). Tests: OAuthInterceptorScopeEnforcementUnitTest and OAuthInterceptorAuditLoggingUnitTest updated to mock the single findUnexpiredAccessToken returning a ServiceAccessToken; OscarOAuthDataProviderUnitTest covers findUnexpiredAccessToken (provider+scopes, expiry); OAuthScopesUnitTest asserts OPTIONS/TRACE resolve to .read. Affected OAuth suite: 63 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca>
|
@yingbull this is waiting for PR 3094, please ping me here when that is merged. :) |
…20260630151842 Signed-off-by: Ben Heerema <ben@maplecreekmedical.ca> # Conflicts: # src/main/java/io/github/carlos_emr/carlos/login/OscarOAuthDataProvider.java # src/main/java/io/github/carlos_emr/carlos/webserv/oauth/OAuthScopes.java # src/main/java/io/github/carlos_emr/carlos/webserv/oauth/util/OAuthInterceptor.java # src/test/java/io/github/carlos_emr/carlos/login/OscarOAuthDataProviderUnitTest.java # src/test/java/io/github/carlos_emr/carlos/login/OscarRequestTokenServiceScopeValidationUnitTest.java # src/test/java/io/github/carlos_emr/carlos/webserv/oauth/OAuthScopesUnitTest.java # src/test/java/io/github/carlos_emr/carlos/webserv/oauth/util/OAuthInterceptorScopeEnforcementUnitTest.java
This comment was marked as outdated.
This comment was marked as outdated.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No issues found across 2 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Architecture diagram
sequenceDiagram
participant Client as OAuth Client App
participant Initiate as InitiateEndpoint /ws/oauth/initiate
participant Interceptor as OAuthInterceptor (CXF Filter)
participant Scopes as OAuthScopes Resolver
participant Service as JAX-RS Service /ws/services/{root}/...
participant Provider as OscarOAuthDataProvider
Note over Client,Provider: OAuth 1.0a Scope Enforcement Flow (flag-gated)
Client->>Initiate: POST /ws/oauth/initiate (request scopes)
Initiate->>Scopes: trim/split scope string, isKnownScope(scope)
alt flag=oauth.scope.enforcement.enabled
Scopes-->>Initiate: known scope?
Initiate-->>Client: 400 invalid_scope if unknown/empty
else flag disabled
Initiate-->>Client: lenient, accept any scopes
end
Client->>Interceptor: Request to /ws/services/{root}/... (with HTTP method + access token)
Note over Interceptor,Service: NEW: Full service map & per-endpoint read/write
Interceptor->>Interceptor: Extract path via getPathInfo(), lower-case root
Interceptor->>Scopes: requiredScope(httpMethod, path)
alt Root NOT in DOMAIN_BY_PATH_ROOT
Scopes-->>Interceptor: NO_SCOPE_REQUIRED (null)
note over Interceptor,Service: No enforcement, pass through
Interceptor->>Service: Forward request (unrestricted)
Service-->>Client: Response
else Root mapped to domain
alt Safe method (GET/HEAD/OPTIONS)
Scopes-->>Interceptor: {domain}.read
else Non-safe method (POST/PUT/DELETE)
alt POST + path matches READ_OP_TEMPLATES_BY_ROOT or root in NONSAFE_READ_ROOTS
note over Scopes: CHANGED: Read-only POSTs now classified as .read
Scopes-->>Interceptor: {domain}.read
else POST + no template match, or PUT/DELETE, or null/blank method
note over Scopes: Ambiguous defaults to .write (secure direction)
Scopes-->>Interceptor: {domain}.write
end
end
alt flag=oauth.scope.enforcement.enabled
Interceptor->>Provider: Validate token has required scope
alt Token satisfies scope (write implies read)
Provider-->>Interceptor: authorized
Interceptor->>Service: Forward request
Service-->>Client: Response (200/2xx)
else Scope insufficient or null/blank persisted scopes
note over Provider: CHANGED: null scopes → empty grant (fail closed)
Provider-->>Interceptor: 403 insufficient_scope
Interceptor-->>Client: 403 Forbidden
end
else flag disabled
Interceptor->>Service: Forward request (no enforcement)
Service-->>Client: Response
end
end
|
|
✅ Action performedReview finished.
|
There was a problem hiding this comment.
No issues found across 2 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Architecture diagram
sequenceDiagram
participant Client as OAuth Client App
participant Initiate as InitiateEndpoint /ws/oauth/initiate
participant Interceptor as OAuthInterceptor (CXF Filter)
participant Scopes as OAuthScopes Resolver
participant Service as JAX-RS Service /ws/services/{root}/...
participant Provider as OscarOAuthDataProvider
Note over Client,Provider: OAuth 1.0a Scope Enforcement Flow (flag-gated)
Client->>Initiate: POST /ws/oauth/initiate (request scopes)
Initiate->>Scopes: trim/split scope string, isKnownScope(scope)
alt flag=oauth.scope.enforcement.enabled
Scopes-->>Initiate: known scope?
Initiate-->>Client: 400 invalid_scope if unknown/empty
else flag disabled
Initiate-->>Client: lenient, accept any scopes
end
Client->>Interceptor: Request to /ws/services/{root}/... (with HTTP method + access token)
Note over Interceptor,Service: NEW: Full service map & per-endpoint read/write
Interceptor->>Interceptor: Extract path via getPathInfo(), lower-case root
Interceptor->>Scopes: requiredScope(httpMethod, path)
alt Root NOT in DOMAIN_BY_PATH_ROOT
Scopes-->>Interceptor: NO_SCOPE_REQUIRED (null)
note over Interceptor,Service: No enforcement, pass through
Interceptor->>Service: Forward request (unrestricted)
Service-->>Client: Response
else Root mapped to domain
alt Safe method (GET/HEAD/OPTIONS)
Scopes-->>Interceptor: {domain}.read
else Non-safe method (POST/PUT/DELETE)
alt POST + path matches READ_OP_TEMPLATES_BY_ROOT or root in NONSAFE_READ_ROOTS
note over Scopes: CHANGED: Read-only POSTs now classified as .read
Scopes-->>Interceptor: {domain}.read
else POST + no template match, or PUT/DELETE, or null/blank method
note over Scopes: Ambiguous defaults to .write (secure direction)
Scopes-->>Interceptor: {domain}.write
end
end
alt flag=oauth.scope.enforcement.enabled
Interceptor->>Provider: Validate token has required scope
alt Token satisfies scope (write implies read)
Provider-->>Interceptor: authorized
Interceptor->>Service: Forward request
Service-->>Client: Response (200/2xx)
else Scope insufficient or null/blank persisted scopes
note over Provider: CHANGED: null scopes → empty grant (fail closed)
Provider-->>Interceptor: 403 insufficient_scope
Interceptor-->>Client: 403 Forbidden
end
else flag disabled
Interceptor->>Service: Forward request (no enforcement)
Service-->>Client: Response
end
end
Reviewer's GuideExtends OAuth scope handling to cover all /ws/services JAX-RS roots with a domain map and introduces per-endpoint read/write classification, including explicit handling of read-only POSTs, along with unit tests that validate the new mapping, classification logic, and scope vocabulary. Sequence diagram for OAuth scope resolution with per-endpoint read/write classificationsequenceDiagram
actor Client
participant HttpServletRequest
participant OAuthInterceptor
participant OAuthScopes
Client->>HttpServletRequest: POST /ws/services/tickler/search
HttpServletRequest-->>OAuthInterceptor: getMethod() = POST
HttpServletRequest-->>OAuthInterceptor: getPathInfo() = /services/tickler/search
OAuthInterceptor->>OAuthScopes: requiredScope("POST", "/services/tickler/search")
activate OAuthScopes
OAuthScopes->>OAuthScopes: serviceSegments("/services/tickler/search")
OAuthScopes-->>OAuthScopes: segments = ["tickler", "search"]
OAuthScopes->>OAuthScopes: DOMAIN_BY_PATH_ROOT.get("tickler")
OAuthScopes-->>OAuthScopes: domain = "tickler"
OAuthScopes->>OAuthScopes: isSafeMethod("POST")
OAuthScopes-->>OAuthScopes: false
OAuthScopes->>OAuthScopes: isPostMethod("POST")
OAuthScopes-->>OAuthScopes: true
OAuthScopes->>OAuthScopes: isNonSafeRead("tickler", ["tickler","search"])
OAuthScopes->>OAuthScopes: READ_OP_TEMPLATES_BY_ROOT.get("tickler")
OAuthScopes-->>OAuthScopes: templates = [["search"]]
OAuthScopes->>OAuthScopes: matchesTemplate(["search"], ["search"])
OAuthScopes-->>OAuthScopes: true
OAuthScopes-->>OAuthInterceptor: "tickler.read"
deactivate OAuthScopes
OAuthInterceptor-->>Client: 200 OK (if token has tickler.read or tickler.write)
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- The read/write overrides in
NONSAFE_READ_ROOTSandREAD_OP_TEMPLATES_BY_ROOTare fully string-based; consider adding a one-time startup/assertion check that all these roots exist inDOMAIN_BY_PATH_ROOTso any future typo or service rename fails fast rather than silently disabling an override. requiredScoperepeatedly normalizes the HTTP method (e.g., inisSafeMethodandisPostMethod); you could normalize/trim/lowercase once up front and pass around the normalized value to simplify the logic and avoid duplicate work.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The read/write overrides in `NONSAFE_READ_ROOTS` and `READ_OP_TEMPLATES_BY_ROOT` are fully string-based; consider adding a one-time startup/assertion check that all these roots exist in `DOMAIN_BY_PATH_ROOT` so any future typo or service rename fails fast rather than silently disabling an override.
- `requiredScope` repeatedly normalizes the HTTP method (e.g., in `isSafeMethod` and `isPostMethod`); you could normalize/trim/lowercase once up front and pass around the normalized value to simplify the logic and avoid duplicate work.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
@yingbull ready for review |



fixes #3102
Important
Stacked on #3094 (
claude/issue-3083-...). #3102 builds onOAuthScopes, which only exists on #3094's branch, so this PR is based on it and targetsdevelop. Until #3094 merges, the diff below includes #3094's commits — review only the single commitfeat(security): per-endpoint read/write scope classification + full service map. After #3094 merges to develop, this diff collapses to just the #3102 change.Problem
#3094 introduced default-OFF OAuth scope enforcement piloted on
schedule/tickler, and derived read vs. write purely from the HTTP method. That over-required.writefor read-only POST endpoints (e.g.tickler/search,schedule/getAppointment) and left ~31 of ~33 services unmapped (NO_SCOPE_REQUIRED). This PR is the #3102 follow-up: complete the map and make read/write per-endpoint.What changed (one file + its test)
DOMAIN_BY_PATH_ROOTnow maps every JAX-RS service root under/ws/servicesto a scope domain (~33 roots → ~28 domains). Related roots share a domain:rx+rxlookup→rx,reporting+reportbytemplate→report,eform+eforms→eform,demographics(+/merge)→demographic. The/initiatevocabulary (KNOWN_SCOPES) is derived from the map, so all domains'.read/.writeare now accepted.NONSAFE_READ_ROOTS— services whose entire non-safe surface is read-only (measurements,recordUX,rxlookup,patientDetailStatusService); andREAD_OP_SEGMENTS_BY_ROOT— read-only POSTs identified by a path-segment marker per root (tickler/search,schedule/getAppointment+appointmentHistory,notes/.../all+getIssueNote+…,demographics/search,consults/searchRequests+searchResponses,reporting/getReport+patientList+runReport,dxRegisty/findLikeIssue,persona/hasRights+isAllowedAccessToPatientRecord+preferences,providerService/.../search,rx/.../print).@GET/@POST/@PUT/@DELETEmethods; ambiguous operations default to.write(the security-safe direction). Resolution mechanism is unchanged — still keyed off the container-canonicalgetPathInfo(). Enforcement remains gated byoauth.scope.enforcement.enabled(default off), so the map can be reviewed/corrected before it is ever enabled.Out of scope (the third part of #3102): flipping the default to ON and the rollout/migration plan — left for a separate decision.
Tests
OAuthScopesUnitTestextended with: full-map domain resolution for newly-covered services, shared-domain roots, read-only-POST overrides (tickler/search → tickler.read,schedule/getAppointment → schedule.read,notes/123/all → note.read,consults/searchRequests → consultation.read,reporting/.../getReport → report.read), wholly-read-only services (measurements/123 → measurement.read,recordUX/searchTemplates → recordux.read,patientDetailStatusService/validateHC → patientstatus.read), mutating-POST writes, unmapped-root →NO_SCOPE_REQUIRED, and the expanded/initiatevocabulary.Local verification
mvn test-compile— BUILD SUCCESS.OAuthScopesUnitTest+OAuthInterceptorScopeEnforcementUnitTest+OscarRequestTokenServiceScopeValidationUnitTest→ 29 tests, 0 failures.🤖 Generated with Claude Code
Summary by cubic
Adds per-endpoint read/write scope classification and a complete service-to-domain map for all
/ws/services, enabling accurate OAuth 1.0a scope checks behindoauth.scope.enforcement.enabled(default off). Also validates scopes at/ws/oauth/initiateand safely handles tokens with no scopes.New Features
OAuthScopes: maps every service root to a domain and classifies read/write per endpoint. Safe methods → read; non-safe → write, with POST-only read overrides matched by positional templates (wildcards for params) to prevent param-value escalation. Write implies read; unmapped roots returnNO_SCOPE_REQUIRED. Note: Disease Registry root is intentionally keyed asdxregistyto match its@Path.OAuthInterceptor: resolves required scope fromHttpServletRequest.getPathInfo()and enforces it when the flag is on; rejects out-of-scope calls with 403insufficient_scope. Non-POST or blank/null methods default to write for fail-safe handling.OscarRequestTokenService: trims/splits requested scopes and, when the flag is on, rejects empty or unknown scopes at/initiatewith 400invalid_scope; remains lenient when off.Bug Fixes
OscarOAuthDataProvider: treats null/blank persisted scopes as an empty grant to fail closed (403) instead of throwing a 500.Written for commit 1841680. Summary will update on new commits.
Summary by Sourcery
Expand OAuth scope resolution to cover all JAX-RS services under /ws/services with per-endpoint read/write classification and update tests accordingly.
New Features:
Enhancements:
Tests: