Skip to content

Commit 6dabfcd

Browse files
notSumit25claude
andcommitted
fix: authorize every connection-scoped endpoint (116 were unguarded)
12 controllers took a caller-supplied connectionId and never checked it. SecurityConfig only asserts .anyRequest().authenticated() and no filter, interceptor or aspect inspects a connection id, so authentication was the only barrier. Verified against a running install, not inferred: a DEVELOPER holding no grant on any connection could - read literal-bearing slow-query SQL with real customer ids and names (GET /slow-query-analytics/{id}/query/{fp}/samples returned 200 while GET /slow-log-source/{id} returned 403 in the same session), - enumerate another tenant's schema and table statistics, via two endpoints that decrypt the target connection's credentials and open a live JDBC session (/tenant-column-suggestions and /config), - and permanently delete that tenant's analysis history (DELETE /slow-queries/history/connection/{id} -> 200, row gone). Affected: SlowQueryController (43), SlowQueryAnalyticsController (13), SchemaChangeController (13), SentinelAnalyticsController (10), PerformanceActionController (9), QueryPerformanceController (8), QueryPlanController (8), IndexAdvisorController (7), PerformanceInsightsController (5), AdvisorController (3), ResourceLimitsController (3), BusinessRuleController (3). This is the same class of defect BrainController carried (93 of 116 unguarded). The safety test added then hardcodes one Path.of(...), so it could not see any of these. What changed * 127 guard calls: assertCanReadConnectionContent on reads, assertCanManageConnectionContent on writes and deletes. * An id is not a capability. For endpoints keyed on alertId, actionId, regressionId, recommendationId, fingerprintId, planId, ruleId, snapshotId or historyId, resolve the owning connection and assert on that. 15 new findConnectionIdFor* accessors where no lookup existed. These report 404, not 403, for an unknown id — a 403 confirms the row exists, turning the endpoint into an id oracle, and regressionId is a sequential Long. * Ids arriving in the request body are not constrained by a path-variable check. Four holes survived exactly that kind of fix: - schema-changes/snapshots/compare took two snapshot ids and no connectionId at all, so it would diff tenant A's schema against tenant B's; compareSnapshots now refuses a mismatch outright. - PUT /performance-actions/batch-status took an arbitrary actionIds list with no scope; it now authorizes every id before mutating any, so a mixed batch fails atomically. - changes/acknowledge and regressions/acknowledge authorized the path connection and then acted on whatever ids the body named; allChangesBelongTo / allComparisonsBelongTo verify membership, and an id that resolves to nothing fails too, so unknown ids cannot be mixed into an otherwise valid batch. * Never take the actor from the request. userId was a query parameter and acknowledgedBy/resolvedBy/updatedBy defaulted to the literal string "user", so the acknowledgement trail was unauthenticated free text that could name any colleague. 10 sites now use requireCurrentUsername(). The parameters are still accepted for wire compatibility and ignored. * ConnectionScopedAuthorizationSafetyTest replaces the per-file approach: it scans every *Controller.java, so a new controller is covered the day it is written. Six cases — connection-scoped endpoints authorized, body-supplied id collections scoped, 403 not swallowed into 500, controller advices not swallowing denials, exemptions still true, delegated service checks still present. The exemption list is itself guarded, so it cannot rot into a way of hiding a real gap. Two things found by writing and running the fix, not by reading it * The generalized test immediately found 9 more unguarded endpoints in controllers nobody was looking at: StatsController, ProjectController, DashboardController, and a destructive DELETE /sentinel/demo/cleanup/{connectionId}. Three had been in my draft exemption list on the assumption they were connection-free; they were not. * Testing the fix found a bug reading it never would. 24 endpoints returned 403 and index-advisor returned 500: IndexAdvisorExceptionHandler's @ExceptionHandler(Exception.class) swallowed the denial and reported "Index operation failed" with the 403's text in the body. The guard held, but the response blamed the index store. It now handles ResponseStatusException first, and the safety test asserts no advice with a catch-all omits that. Also drops @crossorigin(origins = "*") from SentinelAnalyticsController. Tested and inert — an evil-origin preflight gets 403 with no Access-Control-Allow-Origin because the SecurityConfig allowlist wins, while an allowed origin gets 200 + ACAO — but it reads like an intentional hole. Verification Real Maven compile of main and test sources, zero errors. SlowQueryControllerS3Test needed the new constructor argument and was updated rather than left red. Live, against the rebuilt image with a DEVELOPER holding no grant on the target connection: - 40/40 previously-leaking reads -> 403 - 10/10 writes and destructive endpoints -> 403, and psql confirms nothing was mutated - 7/7 orphan-id and body-scoped paths -> 404, no existence oracle - 30/30 same endpoint shapes on a granted connection -> 200, zero false denials; confirmed again from a real browser session - index-advisor now returns 403 "Read access denied for this connection" Not covered: mvn test was not executed (the image build uses -DskipTests and this host has no JDK/Maven). The six safety-test cases were validated by re-implementing their scan logic against the tree and the file compiles, but they have not been run by JUnit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f1c03f3 commit 6dabfcd

27 files changed

Lines changed: 1009 additions & 15 deletions

CLAUDE.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,73 @@ it against a real database — not a theoretical hardening pass.
709709
`catch (ResponseStatusException e) { throw e; }` a 403 is swallowed and reported as a
710710
server error, so a client cannot tell "not yours" from "broken". The safety test
711711
asserts this too.
712+
- **Then it happened again, on 12 more controllers — 116 endpoints, zero checks.**
713+
`BrainControllerAuthorizationSafetyTest` hardcodes one `Path.of(...)`, so it could not
714+
see `SlowQueryController` (43), `SlowQueryAnalyticsController` (13),
715+
`SchemaChangeController` (13), `SentinelAnalyticsController` (10),
716+
`PerformanceActionController` (9), `QueryPerformanceController` (8),
717+
`QueryPlanController` (8), `IndexAdvisorController` (7),
718+
`PerformanceInsightsController` (5), `AdvisorController` (3),
719+
`ResourceLimitsController` (3) or `BusinessRuleController` (3). Verified live, not
720+
inferred: a DEVELOPER holding **no grant on any connection** read literal-bearing
721+
slow-query SQL with real customer ids and names
722+
(`/slow-query-analytics/{id}/query/{fp}/samples` → 200 while
723+
`/slow-log-source/{id}` → 403 in the same session), enumerated another tenant's
724+
schema, and **deleted that tenant's analysis history** via
725+
`DELETE /slow-queries/history/connection/{id}`. All 116 are now guarded.
726+
`ConnectionScopedAuthorizationSafetyTest` replaces the per-file approach: it scans
727+
**every** `*Controller.java`, so a new controller is covered the day it is written.
728+
Writing it immediately found 9 more unguarded endpoints in controllers nobody was
729+
looking at, including `StatsController`, `ProjectController`, `DashboardController`
730+
and a destructive `DELETE /sentinel/demo/cleanup/{connectionId}`.
731+
- **Two endpoints decrypted another user's credentials before anyone checked access.**
732+
`GET /slow-query-analytics/{id}/tenant-column-suggestions` and `/config` reach
733+
`suggestTenantColumns``getJdbcTemplateForBackgroundJob`
734+
`credentialService.getDecryptedConnection`, opening a live JDBC session to the target
735+
database. An unguarded read is not only a data leak; it can be a credential-use
736+
primitive. Check before the work, not after.
737+
- **A path-variable sweep is not enough — ids in the request body need their own
738+
check.** Four holes survived exactly that kind of fix: `snapshots/compare` (two
739+
snapshot ids, no `connectionId` at all — it would diff tenant A's schema against
740+
tenant B's), `PUT /performance-actions/batch-status` (an arbitrary `actionIds` list,
741+
no scope), and `changes/acknowledge` / `regressions/acknowledge` (path connection
742+
authorized, body ids unchecked). `allChangesBelongTo` / `allComparisonsBelongTo`
743+
verify membership, and **an id that resolves to nothing fails too** — otherwise
744+
unknown ids can be mixed into an otherwise valid batch. The safety test has a
745+
dedicated case for body-supplied id collections.
746+
- **An id is not a capability.** For `alertId`, `actionId`, `regressionId`,
747+
`recommendationId`, `fingerprintId`, `planId`, `ruleId`, `snapshotId`, `historyId`:
748+
resolve the owning connection and assert on that. Several services had no such
749+
accessor, so `findConnectionIdFor*` was added to `QueryPerformanceService`,
750+
`QueryPlanCacheService`, `SentinelAnalyticsService`, `BusinessRuleMemoryService`,
751+
`SlowQueryAlertService`, `QueryFingerprintService` and `SchemaChangeTrackingService`.
752+
These helpers report **404, not 403**, for an unknown id — a 403 confirms the row
753+
exists, turning the endpoint into an id oracle. `regressionId` is a sequential
754+
`Long`, so that mattered.
755+
- **Never take the actor from the request.** `POST /slow-queries/alerts/{id}/acknowledge`
756+
took `@RequestParam String userId`, and three acknowledge endpoints took
757+
`acknowledgedBy` defaulting to the literal string `"user"` — so the audit trail was
758+
unauthenticated free text and could name any colleague. All seven sites now use
759+
`accessControlService.requireCurrentUsername()`. The parameters are still accepted
760+
(wire compatibility) and ignored.
761+
- **Guarded vs unguarded is an existence oracle.** A guarded endpoint 404s an unknown
762+
connection id (`resolveCurrentUserAccess` wraps the lookup); an unguarded one returned
763+
200. That difference alone enumerated valid connection ids.
764+
- **A `@ControllerAdvice` catch-all swallows a 403 the same way an in-method one does,
765+
and it is easier to miss because it lives in another file.**
766+
`IndexAdvisorExceptionHandler` has `@ExceptionHandler(Exception.class)`, so the newly
767+
added guard on `/index-advisor/{id}/health-report` returned
768+
`500 "Index operation failed"` with the 403's text in the body — the denial held, but
769+
the response blamed the index store. Found by *testing the fix*, not by reading it: the
770+
other 24 endpoints returned 403 and this one did not. It now has an
771+
`@ExceptionHandler(ResponseStatusException.class)` that preserves the status, and
772+
`ConnectionScopedAuthorizationSafetyTest` asserts every advice with a catch-all also
773+
handles `ResponseStatusException`.
774+
- **`@CrossOrigin(origins = "*")` on a controller is dead code here, and worth
775+
deleting.** `SentinelAnalyticsController` carried it. Tested: an evil-origin preflight
776+
gets `403` with no `Access-Control-Allow-Origin` (the `SecurityConfig` allowlist wins),
777+
while an allowed origin gets `200` + ACAO — so the annotation never had effect. It
778+
still reads like an intentional hole to the next person.
712779

713780
### MCP & CLI Release Rules
714781

backend/src/main/java/com/dbaagent/controller/AdvisorController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,31 @@
33
import com.dbaagent.model.IndexRecommendation;
44
import com.dbaagent.model.PerformanceAnalysis;
55
import com.dbaagent.service.DatabaseAdvisorService;
6+
import com.dbaagent.service.security.AccessControlService;
67
import lombok.RequiredArgsConstructor;
78
import lombok.extern.slf4j.Slf4j;
89
import org.springframework.http.ResponseEntity;
910
import org.springframework.web.bind.annotation.*;
1011

1112
import java.util.List;
1213

14+
/**
15+
* REST API for the performance advisor (analysis, missing indexes, health summary).
16+
*
17+
* <p><b>Authorization:</b> every endpoint here takes a caller-supplied connection id, so
18+
* each one asserts access itself ({@code assertCanReadConnectionContent} for reads,
19+
* {@code assertCanManageConnectionContent} for writes). {@code SecurityConfig} only
20+
* requires an authenticated principal — nothing upstream inspects a connection id. See
21+
* {@code ConnectionScopedAuthorizationSafetyTest}.
22+
*/
1323
@RestController
1424
@RequestMapping("/advisor")
1525
@RequiredArgsConstructor
1626
@Slf4j
1727
public class AdvisorController {
1828

1929
private final DatabaseAdvisorService advisorService;
30+
private final AccessControlService accessControlService;
2031

2132
/**
2233
* Get comprehensive performance analysis
@@ -25,6 +36,7 @@ public class AdvisorController {
2536
public ResponseEntity<PerformanceAnalysis> analyzePerformance(
2637
@PathVariable String connectionId
2738
) {
39+
accessControlService.assertCanReadConnectionContent(connectionId);
2840
try {
2941
log.info("Performance analysis requested for connection: {}", connectionId);
3042
PerformanceAnalysis analysis = advisorService.analyzePerformance(connectionId);
@@ -44,6 +56,7 @@ public ResponseEntity<PerformanceAnalysis> analyzePerformance(
4456
public ResponseEntity<List<IndexRecommendation>> getMissingIndexes(
4557
@PathVariable String connectionId
4658
) {
59+
accessControlService.assertCanReadConnectionContent(connectionId);
4760
try {
4861
log.info("Index recommendations requested for connection: {}", connectionId);
4962
PerformanceAnalysis analysis = advisorService.analyzePerformance(connectionId);
@@ -63,6 +76,7 @@ public ResponseEntity<List<IndexRecommendation>> getMissingIndexes(
6376
public ResponseEntity<HealthSummary> getHealthSummary(
6477
@PathVariable String connectionId
6578
) {
79+
accessControlService.assertCanReadConnectionContent(connectionId);
6680
try {
6781
log.info("Health summary requested for connection: {}", connectionId);
6882
PerformanceAnalysis analysis = advisorService.analyzePerformance(connectionId);

backend/src/main/java/com/dbaagent/controller/BusinessRuleController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.dbaagent.model.brain.BrainRule;
44
import com.dbaagent.service.BusinessRuleMemoryService;
5+
import com.dbaagent.service.security.AccessControlService;
56
import lombok.RequiredArgsConstructor;
67
import org.springframework.http.ResponseEntity;
78
import org.springframework.web.bind.annotation.*;
@@ -11,13 +12,20 @@
1112

1213
/**
1314
* API endpoints for connection-scoped learned SQL business rules.
15+
*
16+
* <p><b>Authorization:</b> every endpoint here takes a caller-supplied connection id, so
17+
* each one asserts access itself ({@code assertCanReadConnectionContent} for reads,
18+
* {@code assertCanManageConnectionContent} for writes). {@code SecurityConfig} only
19+
* requires an authenticated principal — nothing upstream inspects a connection id. See
20+
* {@code ConnectionScopedAuthorizationSafetyTest}.
1421
*/
1522
@RestController
1623
@RequestMapping("/business-rules")
1724
@RequiredArgsConstructor
1825
public class BusinessRuleController {
1926

2027
private final BusinessRuleMemoryService businessRuleMemoryService;
28+
private final AccessControlService accessControlService;
2129

2230
/**
2331
* Returns all active rules for the connection plus the subset applicable to an optional question.
@@ -26,6 +34,7 @@ public class BusinessRuleController {
2634
public ResponseEntity<Map<String, Object>> getRules(
2735
@PathVariable String connectionId,
2836
@RequestParam(required = false) String question) {
37+
accessControlService.assertCanReadConnectionContent(connectionId);
2938
List<BrainRule> activeRules = businessRuleMemoryService.getActiveRules(connectionId);
3039
List<BusinessRuleMemoryService.SqlGuardrail> applicable = businessRuleMemoryService
3140
.resolveApplicableGuardrails(connectionId, question, null);
@@ -49,6 +58,7 @@ public ResponseEntity<Map<String, Object>> getRules(
4958
public ResponseEntity<Map<String, Object>> learn(
5059
@PathVariable String connectionId,
5160
@RequestBody LearnRuleRequest request) {
61+
accessControlService.assertCanManageConnectionContent(connectionId);
5262
int learned = businessRuleMemoryService.learnFromFeedback(
5363
connectionId,
5464
request.text(),
@@ -70,6 +80,10 @@ public ResponseEntity<Map<String, Object>> learn(
7080
*/
7181
@DeleteMapping("/rule/{ruleId}")
7282
public ResponseEntity<Map<String, Object>> deactivateRule(@PathVariable String ruleId) {
83+
String connectionId = businessRuleMemoryService.findConnectionIdForRule(ruleId)
84+
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
85+
org.springframework.http.HttpStatus.NOT_FOUND, "Rule not found"));
86+
accessControlService.assertCanManageConnectionContent(connectionId);
7387
boolean deactivated = businessRuleMemoryService.deactivateRule(ruleId);
7488
return ResponseEntity.ok(Map.of(
7589
"ruleId", ruleId,

backend/src/main/java/com/dbaagent/controller/DashboardController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package com.dbaagent.controller;
22

33
import com.dbaagent.service.DashboardService;
4+
import com.dbaagent.service.security.AccessControlService;
45
import lombok.RequiredArgsConstructor;
56
import lombok.extern.slf4j.Slf4j;
67
import org.springframework.http.ResponseEntity;
78
import org.springframework.web.bind.annotation.*;
89

910
/**
1011
* REST API for performance dashboard
12+
*
13+
* <p><b>Authorization:</b> every endpoint here takes a caller-supplied connection id, so
14+
* each one asserts access itself ({@code assertCanReadConnectionContent} for reads,
15+
* {@code assertCanManageConnectionContent} for writes). {@code SecurityConfig} only
16+
* requires an authenticated principal — nothing upstream inspects a connection id. See
17+
* {@code ConnectionScopedAuthorizationSafetyTest}.
1118
*/
1219
@RestController
1320
@RequestMapping("/dashboard")
@@ -16,6 +23,7 @@
1623
public class DashboardController {
1724

1825
private final DashboardService dashboardService;
26+
private final AccessControlService accessControlService;
1927

2028
/**
2129
* Get performance dashboard data for a connection
@@ -26,6 +34,7 @@ public ResponseEntity<DashboardService.DashboardData> getPerformanceDashboard(
2634
@RequestParam(required = false, defaultValue = "30") Integer days
2735
) {
2836
try {
37+
accessControlService.assertCanReadConnectionContent(connectionId);
2938
log.info("Fetching performance dashboard for connection: {}, days: {}", connectionId, days);
3039

3140
DashboardService.DashboardData data = dashboardService.getDashboardData(connectionId, days);

backend/src/main/java/com/dbaagent/controller/IndexAdvisorController.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.dbaagent.service.IndexAdvisorService;
44
import com.dbaagent.service.PerformanceMonitoringService;
5+
import com.dbaagent.service.security.AccessControlService;
56
import lombok.RequiredArgsConstructor;
67
import lombok.extern.slf4j.Slf4j;
78
import org.springframework.http.ResponseEntity;
@@ -12,6 +13,12 @@
1213

1314
/**
1415
* REST API for enhanced index advisor functionality
16+
*
17+
* <p><b>Authorization:</b> every endpoint here takes a caller-supplied connection id, so
18+
* each one asserts access itself ({@code assertCanReadConnectionContent} for reads,
19+
* {@code assertCanManageConnectionContent} for writes). {@code SecurityConfig} only
20+
* requires an authenticated principal — nothing upstream inspects a connection id. See
21+
* {@code ConnectionScopedAuthorizationSafetyTest}.
1522
*/
1623
@RestController
1724
@RequestMapping("/index-advisor")
@@ -21,12 +28,14 @@ public class IndexAdvisorController {
2128

2229
private final IndexAdvisorService indexAdvisorService;
2330
private final PerformanceMonitoringService performanceMonitoringService;
31+
private final AccessControlService accessControlService;
2432

2533
/**
2634
* Get comprehensive index health report
2735
*/
2836
@GetMapping("/{connectionId}/health-report")
2937
public ResponseEntity<Map<String, Object>> getHealthReport(@PathVariable String connectionId) {
38+
accessControlService.assertCanReadConnectionContent(connectionId);
3039
return ResponseEntity.ok(indexAdvisorService.getIndexHealthReport(connectionId));
3140
}
3241

@@ -35,6 +44,7 @@ public ResponseEntity<Map<String, Object>> getHealthReport(@PathVariable String
3544
*/
3645
@GetMapping("/{connectionId}/unused")
3746
public ResponseEntity<List<Map<String, Object>>> getUnusedIndexes(@PathVariable String connectionId) {
47+
accessControlService.assertCanReadConnectionContent(connectionId);
3848
return ResponseEntity.ok(performanceMonitoringService.getUnusedIndexes(connectionId));
3949
}
4050

@@ -43,6 +53,7 @@ public ResponseEntity<List<Map<String, Object>>> getUnusedIndexes(@PathVariable
4353
*/
4454
@GetMapping("/{connectionId}/duplicates")
4555
public ResponseEntity<List<Map<String, Object>>> getDuplicateIndexes(@PathVariable String connectionId) {
56+
accessControlService.assertCanReadConnectionContent(connectionId);
4657
return ResponseEntity.ok(performanceMonitoringService.getDuplicateIndexes(connectionId));
4758
}
4859

@@ -53,6 +64,7 @@ public ResponseEntity<List<Map<String, Object>>> getDuplicateIndexes(@PathVariab
5364
public ResponseEntity<Map<String, Object>> estimateIndexCreation(
5465
@PathVariable String connectionId,
5566
@RequestBody Map<String, Object> request) {
67+
accessControlService.assertCanReadConnectionContent(connectionId);
5668

5769
String tableName = (String) request.get("tableName");
5870
@SuppressWarnings("unchecked")
@@ -75,6 +87,7 @@ public ResponseEntity<Map<String, Object>> estimateIndexCreation(
7587
public ResponseEntity<Map<String, Object>> estimateIndexDrop(
7688
@PathVariable String connectionId,
7789
@RequestBody Map<String, Object> request) {
90+
accessControlService.assertCanReadConnectionContent(connectionId);
7891

7992
String tableName = (String) request.get("tableName");
8093
String indexName = (String) request.get("indexName");
@@ -94,6 +107,7 @@ public ResponseEntity<Map<String, Object>> estimateIndexDrop(
94107
public ResponseEntity<List<Map<String, Object>>> getIndexUsageStats(
95108
@PathVariable String connectionId,
96109
@PathVariable String tableName) {
110+
accessControlService.assertCanReadConnectionContent(connectionId);
97111
return ResponseEntity.ok(performanceMonitoringService.getIndexUsageStats(connectionId, tableName));
98112
}
99113

@@ -102,6 +116,7 @@ public ResponseEntity<List<Map<String, Object>>> getIndexUsageStats(
102116
*/
103117
@GetMapping("/{connectionId}/cache-stats")
104118
public ResponseEntity<Map<String, Double>> getCacheStats(@PathVariable String connectionId) {
119+
accessControlService.assertCanReadConnectionContent(connectionId);
105120
return ResponseEntity.ok(performanceMonitoringService.getCacheHitRatios(connectionId));
106121
}
107122
}

backend/src/main/java/com/dbaagent/controller/IndexAdvisorExceptionHandler.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,27 @@ public ResponseEntity<Map<String, Object>> handleDataAccess(Exception ex) {
6161
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body);
6262
}
6363

64+
/**
65+
* An authorization denial is a deliberate answer, not a failure of this feature.
66+
* {@code handleGeneric} below matches {@code Exception}, so without this more specific
67+
* handler a {@code ResponseStatusException} from
68+
* {@code assertCanReadConnectionContent} was reported as
69+
* {@code 500 "Index operation failed"} — the denial still held, but the caller could
70+
* not tell "not yours" from "the index store is broken", and the message named the
71+
* wrong subsystem. Verified: a non-granted user hitting
72+
* {@code /index-advisor/{id}/health-report} got a 500 whose body carried the 403 text.
73+
*/
74+
@ExceptionHandler(org.springframework.web.server.ResponseStatusException.class)
75+
public ResponseEntity<Map<String, Object>> handleStatus(
76+
org.springframework.web.server.ResponseStatusException ex) {
77+
Map<String, Object> body = new LinkedHashMap<>();
78+
body.put("timestamp", Instant.now().toString());
79+
body.put("status", ex.getStatusCode().value());
80+
body.put("error", ex.getStatusCode().toString());
81+
body.put("message", ex.getReason() != null ? ex.getReason() : ex.getMessage());
82+
return ResponseEntity.status(ex.getStatusCode()).body(body);
83+
}
84+
6485
/** Any other uncaught error from these endpoints → a clean message, not an opaque 500. */
6586
@ExceptionHandler(Exception.class)
6687
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {

0 commit comments

Comments
 (0)