Skip to content

Commit f723a13

Browse files
notSumit25claude
andcommitted
fix: close the three gaps cursor[bot] found on #86
All three were real. Verified each against the code before fixing, and each fix against the running backend after. 1. ProjectController was half-open, and the safety test could not see it Only `listProjects` with a non-null connectionId was guarded. `createProject` took the connection from `request.getConnectionId()`, and the three projectId-keyed endpoints had no check at all — so any authenticated user could read, rename or delete another tenant's project. Root cause was the scanner, exactly as reported: `touchesAConnection` matched the literal lowercase `connectionId`, so `getConnectionId()` (capital C) did not register, and `projectId` was absent from its hand-written id allowlist. Four unguarded endpoints were invisible while the suite reported every case green. My javadoc on that controller claimed "every endpoint here asserts access itself", which was false. Fixed both halves. The connection match is now case-insensitive (`(?i)connection_?id`), and the id rule is inverted: *any* `@PathVariable ...Id` counts as connection-owned until proven otherwise, with real exceptions listed in NOT_CONNECTION_OWNED_IDS alongside the reason. An allowlist can only catch the ids someone remembered to add; this way an omission fails the build instead of passing silently. Inverting it surfaced four PlaybookController endpoints. Those are true negatives — `Playbook` has no connectionId field, playbooks are global templates, and the endpoints in that file which *do* carry a connection are already guarded. `playbookId` is therefore exempt, and `playbookExemptionHoldsOnlyWhilePlaybooksAreConnectionFree` fails the build if a connectionId is ever added to the entity, so the exemption cannot start hiding those four endpoints later. `GET /projects` with no filter spans every connection and cannot be authorized against one grant, so it now filters to connections the caller can read, resolving access once per distinct connectionId rather than once per project (`ConnectionAccessService.resolveAccess` is uncached and hits the grant table). 2. setBaseline trusted the path connectionId while mutating an unconstrained snapshotId `POST /schema-changes/{connectionId}/snapshots/{snapshotId}/set-baseline` asserted manage on the connection, then flipped whatever snapshot id it was handed to BASELINE and pointed that connection's drift config at it. Manage access on A was enough to retarget B's snapshot and bind A's baseline to it — the same id-mismatch class this branch claimed to have closed, split across two path variables instead of hiding in a body. The controller now binds the snapshot to the path connection, and `setBaseline` enforces it again in the service so the invariant does not depend on the caller. It throws rather than skipping: silently no-op'ing the snapshot write while still writing the drift config would leave the config referencing another connection's snapshot. 3. The existence oracle was still open, and the comments claimed otherwise The id helpers 404'd an unknown id but left an existing-but-unauthorized row at 403, so the pair still confirmed which ids are real — `query_performance_regression.id` is a sequential Long, so walking 1..N would have mapped every tenant's regressions. The code comments asserting "404 so it cannot be used to probe" described only the half that was implemented. Added `assertCanRead/ManageConnectionContentOrNotFound`, which answers 404 for both cases, matching what `DashboardWorkspaceService.assertCanReadDashboard` already does for a dashboard outside the caller's workspace. Applied to all nine id-keyed helpers. Endpoints keyed on a connectionId keep 403 on purpose: the caller already knows that connection exists, so an actionable "access denied" beats a misleading 404. Comments corrected to state the property the code now has. Medium items from the same review * `compareSnapshots` and `setBaseline` threw IllegalArgumentException for a missing or cross-connection snapshot, which surfaced as 500. Both now map it to 404 — "not something you can compare" is not a server fault, and a 500 reads as a broken feature. * Remaining actor fields: Sentinel's `initiatedBy` came from the request body, and `acknowledgedBy` still defaulted to the literal string "user" in three places. All now use requireCurrentUsername(). The parameters stay accepted and ignored, noted at each site so nobody re-wires them. Verification Real Maven compile of main and test sources, zero errors. All seven safety-test cases green against the tree. Live, against the rebuilt image with a DEVELOPER who has manage on connection G and no grant on connection U: - POST /projects on U -> 403; on G -> 200 - GET/PUT/DELETE a project owned by U -> 404, 404, 404 (not 403, not 200); row intact afterwards; not leaked through the unfiltered list - set-baseline binding U's snapshot as G's baseline -> 404; snapshot stayed MANUAL; G's drift config still unbound - existing-but-unauthorized vs nonexistent history id -> 404 and 404, indistinguishable; row survived the refused DELETE - 8/8 reads on G still non-403, and the project list shows only G's project Not covered: mvn test still has not been executed (the image build uses -DskipTests and this host has no JDK/Maven), so the seven cases are compile-verified and logic-validated but not JUnit-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6dabfcd commit f723a13

11 files changed

Lines changed: 307 additions & 59 deletions

CLAUDE.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -749,18 +749,48 @@ it against a real database — not a theoretical hardening pass.
749749
accessor, so `findConnectionIdFor*` was added to `QueryPerformanceService`,
750750
`QueryPlanCacheService`, `SentinelAnalyticsService`, `BusinessRuleMemoryService`,
751751
`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.
752+
These helpers report **404 for both** "no such id" and "not yours", via
753+
`assertCanRead/ManageConnectionContentOrNotFound`. The first attempt only 404'd the
754+
*unknown* case and left an authorized-but-denied row at 403, which still confirms the
755+
row exists — a review caught that the code comments claimed a property the code did not
756+
have. `query_performance_regression.id` is a sequential `Long`, so walking 1..N would
757+
have mapped every tenant's regressions. Same answer
758+
`DashboardWorkspaceService.assertCanReadDashboard` already gives. Endpoints keyed on a
759+
**connectionId** keep 403: the caller already knows that connection exists, so an
760+
actionable "access denied" is better than a misleading 404.
755761
- **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.
762+
took `@RequestParam String userId`; `acknowledgedBy` defaulted to the literal string
763+
`"user"`; `resolvedBy`, `updatedBy` and Sentinel's `initiatedBy` came from the request
764+
body — so the audit trail was unauthenticated free text and could name any colleague.
765+
All of them now use `accessControlService.requireCurrentUsername()`. The parameters are
766+
still accepted (wire compatibility) and ignored, which is noted at each site so nobody
767+
re-wires them.
761768
- **Guarded vs unguarded is an existence oracle.** A guarded endpoint 404s an unknown
762769
connection id (`resolveCurrentUserAccess` wraps the lookup); an unguarded one returned
763770
200. That difference alone enumerated valid connection ids.
771+
- **A scanner built on an allowlist of id names can only catch the ids someone
772+
remembered.** `ConnectionScopedAuthorizationSafetyTest` first matched
773+
`body.contains("connectionId")` plus a hand-written list
774+
(`alertId|actionId|regressionId|…`). Both halves leaked: `ProjectController.createProject`
775+
reads `request.getConnectionId()`**capital C** — and `projectId` was not in the list,
776+
so `POST /projects` and `GET|PUT|DELETE /projects/{projectId}` were invisible while the
777+
suite reported every case green. Now the connection match is case-insensitive and *any*
778+
`@PathVariable …Id` counts as connection-owned until proven otherwise, with genuine
779+
exceptions in `NOT_CONNECTION_OWNED_IDS` carrying a reason. Inverting it immediately
780+
surfaced four `PlaybookController` endpoints — those turned out to be true negatives
781+
(`Playbook` has no `connectionId`; playbooks are global templates), and
782+
`playbookExemptionHoldsOnlyWhilePlaybooksAreConnectionFree` fails the build if a
783+
`connectionId` is ever added to that entity. **A safety test that reports green is
784+
evidence only about what it can see.**
785+
- **Two path variables are as dangerous as a body id.**
786+
`POST /schema-changes/{connectionId}/snapshots/{snapshotId}/set-baseline` authorized the
787+
connection and then flipped *whatever snapshot id it was handed* to BASELINE and pointed
788+
that connection's drift config at it — so manage access on A could retarget B's snapshot
789+
and bind A's baseline to it. `setBaseline` now refuses a snapshot whose `connectionId`
790+
differs, in the service as well as the controller, and **throws rather than silently
791+
skipping**: no-op'ing the snapshot write while still writing the drift config would leave
792+
the config pointing at another connection's snapshot. When a handler takes an id
793+
alongside a `connectionId`, authorizing the connection is half the check.
764794
- **A `@ControllerAdvice` catch-all swallows a 403 the same way an in-method one does,
765795
and it is easier to miss because it lives in another file.**
766796
`IndexAdvisorExceptionHandler` has `@ExceptionHandler(Exception.class)`, so the newly

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,12 +298,13 @@ public static class AffectedQueryItem {
298298
/**
299299
* Authorize a write keyed only on an action id. The action carries its own
300300
* connectionId, so resolve that first and assert against it — an action id
301-
* is not a capability. An unknown id reports 404 rather than 403 so the
302-
* endpoint cannot be used to probe which action ids exist.
301+
* is not a capability. An unknown id and one on a connection the caller cannot
302+
* manage both report 404, so the endpoint cannot be used to probe which action
303+
* ids exist.
303304
*/
304305
private void assertCanManageAction(String actionId) {
305306
PerformanceAction action = aggregatorService.getActionById(actionId)
306307
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Action not found"));
307-
accessControlService.assertCanManageConnectionContent(action.getConnectionId());
308+
accessControlService.assertCanManageConnectionContentOrNotFound(action.getConnectionId(), "Action");
308309
}
309310
}

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

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@
88
import org.springframework.http.ResponseEntity;
99
import org.springframework.web.bind.annotation.*;
1010

11+
import java.util.HashMap;
1112
import java.util.List;
13+
import java.util.Map;
1214

1315
/**
1416
* REST API for projects, optionally filtered by connection.
1517
*
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}.
18+
* <p><b>Authorization:</b> a project belongs to a connection, so every endpoint is gated
19+
* on that connection's ACL — directly where the request carries a {@code connectionId},
20+
* and via the project's own {@code connectionId} where the path carries only a
21+
* {@code projectId}. {@code SecurityConfig} only requires an authenticated principal;
22+
* nothing upstream inspects a connection id.
23+
*
24+
* <p>The id-keyed endpoints report 404 rather than 403 for a project the caller may not
25+
* touch, so the route cannot be used to test which project ids exist.
2126
*/
2227
@RestController
2328
@RequestMapping("/projects")
@@ -28,6 +33,7 @@ public class ProjectController {
2833

2934
@PostMapping
3035
public ResponseEntity<Project> createProject(@RequestBody CreateProjectRequest request) {
36+
accessControlService.assertCanManageConnectionContent(request.getConnectionId());
3137
Project project = projectService.createProject(
3238
request.getName(),
3339
request.getDescription(),
@@ -42,32 +48,42 @@ public ResponseEntity<List<Project>> listProjects(
4248
) {
4349
if (connectionId != null) {
4450
accessControlService.assertCanReadConnectionContent(connectionId);
51+
return ResponseEntity.ok(projectService.getProjectsByConnection(connectionId));
4552
}
46-
List<Project> projects = connectionId != null
47-
? projectService.getProjectsByConnection(connectionId)
48-
: projectService.getAllProjects();
49-
return ResponseEntity.ok(projects);
53+
// No filter means "every project on every connection", which cannot be authorized
54+
// against a single connection's grants — so it is scoped to the caller instead.
55+
// Access is resolved once per distinct connection, not once per project:
56+
// ConnectionAccessService.resolveAccess is uncached and hits the grant table, and
57+
// many projects share a connection.
58+
Map<String, Boolean> readable = new HashMap<>();
59+
return ResponseEntity.ok(projectService.getAllProjects().stream()
60+
.filter(p -> readable.computeIfAbsent(
61+
String.valueOf(p.getConnectionId()), c -> canRead(p.getConnectionId())))
62+
.toList());
5063
}
5164

5265
@GetMapping("/{projectId}")
5366
public ResponseEntity<Project> getProject(@PathVariable String projectId) {
54-
return projectService.getProject(projectId)
55-
.map(ResponseEntity::ok)
56-
.orElse(ResponseEntity.notFound().build());
67+
Project project = requireProject(projectId);
68+
accessControlService.assertCanReadConnectionContentOrNotFound(
69+
project.getConnectionId(), "Project");
70+
return ResponseEntity.ok(project);
5771
}
5872

5973
@PutMapping("/{projectId}")
6074
public ResponseEntity<Project> updateProject(
6175
@PathVariable String projectId,
6276
@RequestBody UpdateProjectRequest request
6377
) {
78+
assertCanManageProject(projectId);
6479
return projectService.updateProject(projectId, request.getName(), request.getDescription())
6580
.map(ResponseEntity::ok)
6681
.orElse(ResponseEntity.notFound().build());
6782
}
6883

6984
@DeleteMapping("/{projectId}")
7085
public ResponseEntity<Void> deleteProject(@PathVariable String projectId) {
86+
assertCanManageProject(projectId);
7187
return projectService.deleteProject(projectId)
7288
? ResponseEntity.ok().build()
7389
: ResponseEntity.notFound().build();
@@ -80,6 +96,31 @@ public static class CreateProjectRequest {
8096
private String connectionId;
8197
}
8298

99+
private Project requireProject(String projectId) {
100+
return projectService.getProject(projectId)
101+
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
102+
org.springframework.http.HttpStatus.NOT_FOUND, "Project not found"));
103+
}
104+
105+
/** A project id is not a capability: authorize the connection that owns the project. */
106+
private void assertCanManageProject(String projectId) {
107+
accessControlService.assertCanManageConnectionContentOrNotFound(
108+
requireProject(projectId).getConnectionId(), "Project");
109+
}
110+
111+
/** Non-throwing read check, for filtering a cross-connection list. */
112+
private boolean canRead(String connectionId) {
113+
if (connectionId == null) {
114+
return false;
115+
}
116+
try {
117+
accessControlService.assertCanReadConnectionContent(connectionId);
118+
return true;
119+
} catch (org.springframework.web.server.ResponseStatusException e) {
120+
return false;
121+
}
122+
}
123+
83124
@Data
84125
public static class UpdateProjectRequest {
85126
private String name;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,14 +280,15 @@ public ResponseEntity<Map<String, Object>> triggerAnalysis(@PathVariable String
280280
* Authorize a write keyed only on a regression id. The regression carries
281281
* its own connectionId, so resolve that and assert against it — a
282282
* regression id is not a capability, and these ids are sequential Longs,
283-
* so they are trivially enumerable. An unknown id reports 404 so the
284-
* endpoint cannot be used to probe which regressions exist.
283+
* so they are trivially enumerable — walking 1..N would otherwise map out every
284+
* tenant's regressions. An unknown id and one the caller cannot manage both report
285+
* 404, so the response does not distinguish them.
285286
*/
286287
private void assertCanManageRegression(Long regressionId) {
287288
String connectionId = queryPerformanceService.findConnectionIdForRegression(regressionId)
288289
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
289290
org.springframework.http.HttpStatus.NOT_FOUND, "Regression not found"));
290-
accessControlService.assertCanManageConnectionContent(connectionId);
291+
accessControlService.assertCanManageConnectionContentOrNotFound(connectionId, "Regression");
291292
}
292293

293294
/** The authenticated caller. Never trust a client-supplied actor name. */

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ public ResponseEntity<List<QueryPlanComparison>> getUnacknowledgedRegressions(@P
108108
public ResponseEntity<Map<String, Object>> acknowledgeRegressions(
109109
@PathVariable String connectionId,
110110
@RequestBody List<String> comparisonIds,
111-
@RequestParam(required = false, defaultValue = "user") String acknowledgedBy) {
111+
@RequestParam(required = false) String acknowledgedBy) {
112+
// Accepted for wire compatibility and deliberately ignored: the actor is
113+
// taken from the security context below. It previously defaulted to the
114+
// literal string "user", so the trail named nobody.
112115
accessControlService.assertCanManageConnectionContent(connectionId);
113116

114117
if (!planCacheService.allComparisonsBelongTo(connectionId, comparisonIds)) {
@@ -135,12 +138,13 @@ public ResponseEntity<Map<String, Object>> getPlanStats(@PathVariable String con
135138
/**
136139
* Authorize a write keyed only on a plan id. The cached plan carries its own
137140
* connectionId, so resolve that and assert against it. An unknown id reports
138-
* 404 so the endpoint cannot be used to probe which plan ids exist.
141+
* 404 — as does a plan belonging to a connection the caller cannot manage, so the
142+
* endpoint cannot be used to probe which plan ids exist.
139143
*/
140144
private void assertCanManagePlan(String planId) {
141145
String connectionId = planCacheService.findConnectionIdForPlan(planId)
142146
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
143147
org.springframework.http.HttpStatus.NOT_FOUND, "Plan not found"));
144-
accessControlService.assertCanManageConnectionContent(connectionId);
148+
accessControlService.assertCanManageConnectionContentOrNotFound(connectionId, "Plan");
145149
}
146150
}

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,14 @@ public ResponseEntity<Map<String, String>> setBaseline(
8080
@PathVariable String connectionId,
8181
@PathVariable String snapshotId) {
8282
accessControlService.assertCanManageConnectionContent(connectionId);
83+
assertSnapshotBelongsTo(connectionId, snapshotId);
8384

84-
schemaChangeService.setBaseline(connectionId, snapshotId);
85+
try {
86+
schemaChangeService.setBaseline(connectionId, snapshotId);
87+
} catch (IllegalArgumentException e) {
88+
throw new org.springframework.web.server.ResponseStatusException(
89+
org.springframework.http.HttpStatus.NOT_FOUND, e.getMessage());
90+
}
8591
return ResponseEntity.ok(Map.of(
8692
"status", "success",
8793
"message", "Snapshot set as baseline"
@@ -98,7 +104,15 @@ public ResponseEntity<List<SchemaChange>> compareSnapshots(
98104

99105
assertCanReadSnapshot(snapshotId1);
100106
assertCanReadSnapshot(snapshotId2);
101-
return ResponseEntity.ok(schemaChangeService.compareSnapshots(snapshotId1, snapshotId2));
107+
try {
108+
return ResponseEntity.ok(schemaChangeService.compareSnapshots(snapshotId1, snapshotId2));
109+
} catch (IllegalArgumentException e) {
110+
// Missing snapshot, or two snapshots from different connections. Both are
111+
// "not something you can compare", not a server fault — a 500 here would read
112+
// as a broken feature and hide the real reason.
113+
throw new org.springframework.web.server.ResponseStatusException(
114+
org.springframework.http.HttpStatus.NOT_FOUND, e.getMessage());
115+
}
102116
}
103117

104118
// ==================== Change Endpoints ====================
@@ -128,7 +142,10 @@ public ResponseEntity<List<SchemaChange>> getUnacknowledgedChanges(@PathVariable
128142
public ResponseEntity<Map<String, Object>> acknowledgeChanges(
129143
@PathVariable String connectionId,
130144
@RequestBody List<String> changeIds,
131-
@RequestParam(required = false, defaultValue = "user") String acknowledgedBy) {
145+
@RequestParam(required = false) String acknowledgedBy) {
146+
// Accepted for wire compatibility and deliberately ignored: the actor is
147+
// taken from the security context below. It previously defaulted to the
148+
// literal string "user", so the trail named nobody.
132149
accessControlService.assertCanManageConnectionContent(connectionId);
133150

134151
assertChangesBelongTo(connectionId, changeIds);
@@ -145,7 +162,10 @@ public ResponseEntity<Map<String, Object>> acknowledgeChanges(
145162
@PostMapping("/{connectionId}/changes/acknowledge-all")
146163
public ResponseEntity<Map<String, Object>> acknowledgeAllChanges(
147164
@PathVariable String connectionId,
148-
@RequestParam(required = false, defaultValue = "user") String acknowledgedBy) {
165+
@RequestParam(required = false) String acknowledgedBy) {
166+
// Accepted for wire compatibility and deliberately ignored: the actor is
167+
// taken from the security context below. It previously defaulted to the
168+
// literal string "user", so the trail named nobody.
149169
accessControlService.assertCanManageConnectionContent(connectionId);
150170

151171
int count = schemaChangeService.acknowledgeAllChanges(
@@ -207,13 +227,14 @@ public ResponseEntity<Map<String, Object>> triggerDriftCheck(@PathVariable Strin
207227
/**
208228
* Authorize a read keyed only on a snapshot id. The snapshot carries its own
209229
* connectionId, so resolve that and assert against it. An unknown id reports
210-
* 404 so the endpoint cannot be used to probe which snapshots exist.
230+
* 404 — as does a snapshot on a connection the caller cannot read, so the endpoint
231+
* cannot be used to probe which snapshots exist.
211232
*/
212233
private void assertCanReadSnapshot(String snapshotId) {
213234
String connectionId = schemaChangeService.findConnectionIdForSnapshot(snapshotId)
214235
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
215236
org.springframework.http.HttpStatus.NOT_FOUND, "Snapshot not found"));
216-
accessControlService.assertCanReadConnectionContent(connectionId);
237+
accessControlService.assertCanReadConnectionContentOrNotFound(connectionId, "Snapshot");
217238
}
218239

219240
/**
@@ -227,4 +248,19 @@ private void assertChangesBelongTo(String connectionId, List<String> changeIds)
227248
org.springframework.http.HttpStatus.NOT_FOUND, "Change not found for this connection");
228249
}
229250
}
251+
252+
/**
253+
* The snapshot id is a separate path variable from the connection id, so authorizing
254+
* the connection says nothing about the snapshot. Without this a caller with manage
255+
* access on connection A could flip connection B's snapshot to BASELINE and point A's
256+
* drift config at it — the same body/path id-mismatch class as
257+
* {@code changes/acknowledge}, just split across two path variables instead.
258+
*/
259+
private void assertSnapshotBelongsTo(String connectionId, String snapshotId) {
260+
String owner = schemaChangeService.findConnectionIdForSnapshot(snapshotId).orElse(null);
261+
if (!connectionId.equals(owner)) {
262+
throw new org.springframework.web.server.ResponseStatusException(
263+
org.springframework.http.HttpStatus.NOT_FOUND, "Snapshot not found");
264+
}
265+
}
230266
}

0 commit comments

Comments
 (0)