Skip to content

feat(security): per-endpoint read/write scope classification + full service map (#3102)#3103

Open
Ben-Heerema wants to merge 13 commits into
developfrom
claude/issue-3102-20260630151842
Open

feat(security): per-endpoint read/write scope classification + full service map (#3102)#3103
Ben-Heerema wants to merge 13 commits into
developfrom
claude/issue-3102-20260630151842

Conversation

@Ben-Heerema

@Ben-Heerema Ben-Heerema commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

fixes #3102

Important

Stacked on #3094 (claude/issue-3083-...). #3102 builds on OAuthScopes, which only exists on #3094's branch, so this PR is based on it and targets develop. Until #3094 merges, the diff below includes #3094's commits — review only the single commit feat(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 .write for 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_ROOT now maps every JAX-RS service root under /ws/services to a scope domain (~33 roots → ~28 domains). Related roots share a domain: rx+rxlookuprx, reporting+reportbytemplatereport, eform+eformseform, demographics(+/merge)→demographic. The /initiate vocabulary (KNOWN_SCOPES) is derived from the map, so all domains' .read/.write are now accepted.
  • Per-endpoint read/write classification (fixes the cubic P2 from feat(security): enforce OAuth 1.0a scopes behind a feature flag (#3083) #3094): safe methods are reads; non-safe methods are writes except
    • NONSAFE_READ_ROOTS — services whose entire non-safe surface is read-only (measurements, recordUX, rxlookup, patientDetailStatusService); and
    • READ_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).
  • The classification was built by reading every service's @GET/@POST/@PUT/@DELETE methods; ambiguous operations default to .write (the security-safe direction). Resolution mechanism is unchanged — still keyed off the container-canonical getPathInfo(). Enforcement remains gated by oauth.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

OAuthScopesUnitTest extended 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 /initiate vocabulary.

Local verification

  • mvn test-compile — BUILD SUCCESS.
  • OAuthScopesUnitTest + OAuthInterceptorScopeEnforcementUnitTest + OscarRequestTokenServiceScopeValidationUnitTest29 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 behind oauth.scope.enforcement.enabled (default off). Also validates scopes at /ws/oauth/initiate and 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 return NO_SCOPE_REQUIRED. Note: Disease Registry root is intentionally keyed as dxregisty to match its @Path.
    • OAuthInterceptor: resolves required scope from HttpServletRequest.getPathInfo() and enforces it when the flag is on; rejects out-of-scope calls with 403 insufficient_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 /initiate with 400 invalid_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.

Review in cubic

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:

  • Map all service roots under /ws/services to OAuth scope domains and expose the full set of readable/writable scopes for clients.
  • Classify OAuth read vs. write requirements per endpoint, allowing known read-only POST operations to require .read instead of .write scopes.

Enhancements:

  • Treat certain service roots as wholly read-only on non-safe methods and use explicit path templates to safely identify read-only POST operations.
  • Derive the /initiate scope vocabulary from the complete domain map so clients can request scopes for all mapped domains.

Tests:

  • Extend OAuthScopesUnitTest to cover the expanded service-to-domain map, per-endpoint read/write classification, and the updated known-scope vocabulary.

Ben-Heerema and others added 10 commits June 29, 2026 17:22
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>
@Ben-Heerema Ben-Heerema self-assigned this Jun 30, 2026
sourcery-ai[bot]

This comment was marked as outdated.

@coderabbitai

This comment has been minimized.

@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review
@cubic-dev-ai review

@cubic-dev-ai

This comment has been minimized.

@coderabbitai

This comment has been minimized.

@github-actions

This comment has been minimized.

gemini-code-assist[bot]

This comment was marked as outdated.

cubic-dev-ai[bot]

This comment was marked as outdated.

…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>
@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review
@cubic-dev-ai review

@coderabbitai

This comment has been minimized.

@cubic-dev-ai

This comment has been minimized.

cubic-dev-ai[bot]

This comment was marked as outdated.

…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>
@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review
@cubic-dev-ai review

@cubic-dev-ai

This comment has been minimized.

@coderabbitai

This comment has been minimized.

cubic-dev-ai[bot]

This comment was marked as outdated.

@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

Note for future self: 3094 should merge before this!

@Ben-Heerema Ben-Heerema marked this pull request as ready for review June 30, 2026 20:39
@Ben-Heerema Ben-Heerema requested a review from yingbull as a code owner June 30, 2026 20:39
sourcery-ai[bot]

This comment was marked as outdated.

cubic-dev-ai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

@Ben-Heerema Ben-Heerema marked this pull request as draft June 30, 2026 20:49
Ben-Heerema added a commit that referenced this pull request Jun 30, 2026
…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>
@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

@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
@Ben-Heerema

This comment was marked as outdated.

@cubic-dev-ai

This comment has been minimized.

@coderabbitai

This comment has been minimized.

coderabbitai[bot]

This comment was marked as outdated.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Loading

Re-trigger cubic

@sonarqubecloud

sonarqubecloud Bot commented Jul 3, 2026

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@Ben-Heerema Thanks for the update — I'll review the latest changes in #3103 now that the diff has collapsed to the OAuthScopes.java + OAuthScopesUnitTest.java payload from the develop merge. Good to know the earlier findings (TRACE as a safe method, the HashSet import, full CARLOS headers) came in via #3094, and I'll note the duplicate access-token lookup in OAuthInterceptor as a deferred follow-up rather than re-flagging it here.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Ben-Heerema Ben-Heerema marked this pull request as ready for review July 3, 2026 18:32

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Loading

Re-trigger cubic

@sourcery-ai

sourcery-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Extends 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 classification

sequenceDiagram
    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)
Loading

File-Level Changes

Change Details Files
Introduce a full domain map for all /ws/services roots and derive the /initiate scope vocabulary from it.
  • Replace the pilot-only PILOT_DOMAIN_BY_PATH_ROOT with DOMAIN_BY_PATH_ROOT that maps all known JAX-RS service roots to scope domains, including shared domains for related roots and intentional misspelling for dxregisty to match its @path.
  • Update buildKnownScopes to generate KNOWN_SCOPES from DOMAIN_BY_PATH_ROOT so every mapped domain exposes .read and .write at /initiate.
  • Clarify Javadoc to describe the new full-map behavior and that unmapped roots resolve to NO_SCOPE_REQUIRED while enforcement remains gated by a configuration flag.
src/main/java/io/github/carlos_emr/carlos/webserv/oauth/OAuthScopes.java
Classify required read/write scope per endpoint by combining HTTP method safety with explicit read-only POST handling.
  • Extend requiredScope to compute the domain from all path segments under /services/, then decide read vs write per endpoint: safe methods → read; non-safe methods → write unless a POST qualifies for a read override.
  • Add NONSAFE_READ_ROOTS for services whose non-safe surfaces are read-only, and READ_OP_TEMPLATES_BY_ROOT for explicit read-only POST operations keyed by root and path templates, using positional segment matching with "*" wildcards.
  • Introduce helper methods (serviceSegments, isPostMethod, isNonSafeRead, matchesTemplate, seg) to parse and normalize segments, detect POST, test read-only POST cases, and enforce strict template matching that prevents path-parameter values from forcing read classification.
  • Change the path parsing helper from pathRootUnderServices to serviceSegments, returning a lower-cased list of segments after /services/ instead of a single root string.
src/main/java/io/github/carlos_emr/carlos/webserv/oauth/OAuthScopes.java
Expand unit tests to cover the full service map, per-endpoint classification, and updated known-scope vocabulary.
  • Adjust the unmapped-root test to assert NO_SCOPE_REQUIRED for /services/oauth/info and an arbitrary unknown root instead of treating demographics as unpiloted.
  • Add a nested FullMapClassification test suite that validates domain mapping for newly covered services, shared domains across related roots, read-only POST overrides, mutating POSTs remaining writes, wholly read-only services, and guard cases where path-parameter values look like operation names but must not downgrade writes to reads.
  • Add tests confirming that non-POST and null/blank methods are always treated as writes even on read-only services, and that newly mapped domains are accepted by isKnownScope as part of the /initiate vocabulary.
src/test/java/io/github/carlos_emr/carlos/webserv/oauth/OAuthScopesUnitTest.java

Assessment against linked issues

Issue Objective Addressed Explanation
#3102 Replace the HTTP-method-only heuristic with an explicit per-endpoint read/write classification so that read-only POST endpoints (e.g. tickler/search, schedule/getAppointment) require .read instead of .write.
#3102 Define and wire a complete endpoint→scope map for the remaining JAX-RS services under /ws/services/* (beyond schedule/tickler), and ensure the /initiate scope vocabulary reflects all mapped domains.
#3102 Roll out scope enforcement beyond the initial pilot: expand enforcement based on the new map and decide/document the path to flipping oauth.scope.enforcement.enabled to default ON, including migration/communication for existing tokens. The PR explicitly limits itself to computing required scopes (per-endpoint classification and full map) and keeps enforcement gated by the existing feature flag with default OFF. It notes that flipping the default to ON and defining the rollout/migration plan are out of scope and left for a separate decision, and there are no code or documentation changes implementing that rollout.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • 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.
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.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Ben-Heerema

Copy link
Copy Markdown
Collaborator Author

@yingbull ready for review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] OAuth scope enforcement: per-endpoint read/write classification + complete the endpoint→scope map (follow-up to #3083)

1 participant