Skip to content

Commit 2f1b2db

Browse files
Merge branch 'main' into cursor/view-as-policy-identity-c497
Keep fail-closed parse/actor rules and recursive SELECT inspection from this branch, and take main's whole-statement schema allowlist plus assertProtectedTablesAreInspectable. Blank outer-column provenance from a derived table is no longer treated as unresolved so a qualified protection does not catch the same table name in another schema. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
2 parents 3ae8292 + 95b81f9 commit 2f1b2db

8 files changed

Lines changed: 566 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,42 @@ it against a real database — not a theoretical hardening pass.
375375
`POST /users/admin/reset` on every install that had run `setup-agent.sh`, since that
376376
mints an admin MCP token on each run.
377377

378+
### Endpoint Authorization Rules
379+
380+
- **Authentication is not authorization.** `SecurityConfig` only asserts
381+
`.anyRequest().authenticated()` and `JwtAuthenticationFilter` only resolves a
382+
principal — neither looks at a `connectionId`. Connections are **private per user**
383+
(`ConnectionAccessService.resolveAccess` keys on `ownerUsername` plus an explicit
384+
grant table), so any endpoint taking a caller-supplied `connectionId` **must** call
385+
`accessControlService.assertCanReadConnectionContent` (reads) or
386+
`assertCanManageConnectionContent` (writes) itself. There is no filter, interceptor
387+
or aspect that does this for you.
388+
- **`BrainController` shipped with 93 of its 116 endpoints unguarded.** Only the first
389+
~15 (`/understanding`, `/notes/*`, `/tasks/*`, `/key-columns/*`,
390+
`/inferred-relationships/*`) had the check; every later "Phase" block did not — so an
391+
authenticated user could pass someone else's connection id to
392+
`/brain/health-scores/{id}`, `/brain/data-sensitivity/{id}` (which names the PII
393+
columns), `/brain/cost-attribution/{id}`, `/brain/ml-overview/{id}` and ~90 more and
394+
read that user's database intelligence. All 116 are now guarded, and
395+
`BrainControllerAuthorizationSafetyTest` fails the build if a new one is not. The
396+
misses clustered by **when a section was written**, not by read/write semantics —
397+
when adding a controller section, guard it as you write it.
398+
- **When the path carries some other id** (`simulationId`, `experimentId`, `patternId`,
399+
`noteId`, `taskId`), resolve the owning connection first via that service's
400+
`getConnectionId(id)` and assert on the result. Do not skip the check because the
401+
path has no `connectionId` in it.
402+
- **An endpoint with no connection scope at all is admin-only.**
403+
`POST /brain/column-values/embed-all` spans every connection, so it carries
404+
`@PreAuthorize("hasRole('ADMIN')")` — it cannot be authorized against one
405+
connection's grants. `@EnableMethodSecurity(prePostEnabled = true)` is on in
406+
`SecurityConfig`, so `@PreAuthorize` is live.
407+
- **Assert inside the `try`, and rethrow `ResponseStatusException` before the
408+
catch-all.** Every handler in `BrainController` ends with a
409+
`catch (Exception) -> 500`; without the earlier
410+
`catch (ResponseStatusException e) { throw e; }` a 403 is swallowed and reported as a
411+
server error, so a client cannot tell "not yours" from "broken". The safety test
412+
asserts this too.
413+
378414
### MCP & CLI Release Rules
379415

380416
**Whenever you add, rename, or remove an MCP tool or a CLI subcommand, you MUST update all of these in the same commit — they are agent-facing surfaces and drift silently breaks discoverability:**

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

Lines changed: 108 additions & 1 deletion
Large diffs are not rendered by default.

backend/src/main/java/com/dbaagent/service/UserDataAccessPolicyService.java

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public QueryGuardDecision enforcePreExecution(
160160
);
161161
}
162162
enforceAllowedSchemas(select, policy.allowedSchemas());
163+
assertProtectedTablesAreInspectable(inspectable, collectPlainSelects(select), protectedObjects);
163164
QueryInspection inspection = inspectSelect(select, protectedObjects, policy.allowAggregates());
164165
if (inspection.selectsWildcardFromProtectedTable
165166
|| inspection.rawProtectedColumnsSelected
@@ -501,6 +502,18 @@ private Statement unwrapExplain(Statement parsed) {
501502
return parsed;
502503
}
503504

505+
/**
506+
* Enforces the schema allowlist over every table reference in the statement.
507+
*
508+
* Uses JSqlParser's TablesNamesFinder rather than a hand-rolled walk of
509+
* FROM and JOIN. Enumeration has to be exhaustive by construction: an
510+
* allowlist implemented as a partial walk implicitly permits every syntax
511+
* position the walker forgot (subquery, UNION branch, CTE body).
512+
*
513+
* Unqualified names fail closed when a schema allowlist is present: they
514+
* resolve through search_path and could be any schema. CTE aliases are
515+
* skipped because they are not tables.
516+
*/
504517
private void enforceAllowedSchemas(Select select, Set<String> allowedSchemas) {
505518
if (allowedSchemas == null || allowedSchemas.isEmpty()) {
506519
return;
@@ -534,6 +547,125 @@ private void enforceAllowedSchemas(Select select, Set<String> allowedSchemas) {
534547
}
535548
}
536549

550+
/**
551+
* Fails closed on statement shapes column inspection cannot reach.
552+
*
553+
* TablesNamesFinder sees every table in the statement; collectPlainSelects
554+
* does not descend into a select nested inside FROM/JOIN/WHERE/HAVING.
555+
* When a protected table is referenced somewhere the column inspection
556+
* could not examine, refuse the query.
557+
*/
558+
private void assertProtectedTablesAreInspectable(
559+
Statement statement,
560+
List<PlainSelect> inspectedBranches,
561+
Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> protectedObjects
562+
) {
563+
if (protectedObjects == null || protectedObjects.isEmpty()) {
564+
return;
565+
}
566+
Set<String> referenced = new LinkedHashSet<>();
567+
for (String name : new TablesNamesFinder<>().getTables(statement)) {
568+
if (name != null && !name.isBlank()) {
569+
referenced.add(normalizeName(name));
570+
}
571+
}
572+
Set<String> inspected = new LinkedHashSet<>();
573+
for (PlainSelect branch : inspectedBranches) {
574+
collectDirectTables(branch, inspected);
575+
}
576+
for (ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor : protectedObjects.values()) {
577+
String protectedName = descriptor.qualifiedTableName();
578+
boolean isReferenced = referenced.stream().anyMatch(name -> namesMatch(protectedName, name));
579+
boolean wasInspected = inspected.stream().anyMatch(name -> namesMatch(protectedName, name));
580+
if (isReferenced && !wasInspected) {
581+
throw new UserDataAccessPolicyException(
582+
"This query reaches restricted data through a nested query DeepSQL cannot fully verify, so it was blocked before execution.",
583+
"POLICY_SQL_BLOCKED"
584+
);
585+
}
586+
}
587+
}
588+
589+
/**
590+
* Does a query's table reference name the protected table?
591+
*
592+
* Asymmetric on purpose. {@code qualifyTable()} drops the schema when it is
593+
* {@code public}, so a bare protected name means {@code public.<table>}.
594+
* A bare reference in a query resolves through search_path and could be
595+
* any schema, so it matches on bare name (ambiguous → block).
596+
* A qualified protection must not catch the same table name in another
597+
* schema ({@code marts.customer_profiles} vs {@code public.customer_profiles}).
598+
*/
599+
private boolean namesMatch(String protectedName, String referencedName) {
600+
String protectedNorm = normalizeName(protectedName);
601+
String referencedNorm = normalizeName(referencedName);
602+
if (protectedNorm.isEmpty() || referencedNorm.isEmpty()) {
603+
return false;
604+
}
605+
if (!referencedNorm.contains(".")) {
606+
return bareName(protectedNorm).equals(referencedNorm);
607+
}
608+
if (!protectedNorm.contains(".")) {
609+
return referencedNorm.equals("public." + protectedNorm);
610+
}
611+
return protectedNorm.equals(referencedNorm);
612+
}
613+
614+
private String bareName(String normalizedName) {
615+
int dot = normalizedName.lastIndexOf('.');
616+
return dot > 0 && dot < normalizedName.length() - 1
617+
? normalizedName.substring(dot + 1)
618+
: normalizedName;
619+
}
620+
621+
/** Tables named directly in this branch's FROM/JOIN. */
622+
private void collectDirectTables(PlainSelect select, Set<String> out) {
623+
if (select.getFromItem() instanceof Table table) {
624+
out.add(normalizeName(table.getFullyQualifiedName()));
625+
}
626+
if (select.getJoins() != null) {
627+
for (Join join : select.getJoins()) {
628+
if (join.getRightItem() instanceof Table table) {
629+
out.add(normalizeName(table.getFullyQualifiedName()));
630+
}
631+
}
632+
}
633+
}
634+
635+
/**
636+
* Collects every PlainSelect in a statement: the top level, each branch of a
637+
* set operation, parenthesised selects, and every CTE body.
638+
*/
639+
private List<PlainSelect> collectPlainSelects(Select select) {
640+
List<PlainSelect> found = new ArrayList<>();
641+
collectPlainSelects(select, found);
642+
return found;
643+
}
644+
645+
private void collectPlainSelects(Select select, List<PlainSelect> found) {
646+
if (select == null) {
647+
return;
648+
}
649+
if (select.getWithItemsList() != null) {
650+
for (WithItem<?> item : select.getWithItemsList()) {
651+
if (item != null) {
652+
collectPlainSelects(item.getSelect(), found);
653+
}
654+
}
655+
}
656+
if (select instanceof PlainSelect plain) {
657+
found.add(plain);
658+
} else if (select instanceof SetOperationList setOps) {
659+
if (setOps.getSelects() != null) {
660+
for (Select branch : setOps.getSelects()) {
661+
collectPlainSelects(branch, found);
662+
}
663+
}
664+
} else if (select instanceof ParenthesedSelect parenthesed) {
665+
collectPlainSelects(parenthesed.getSelect(), found);
666+
}
667+
}
668+
537669
private Set<String> findReferencedTables(Select select) {
538670
TablesNamesFinder finder = new TablesNamesFinder();
539671
List<String> tableList = finder.getTableList((Statement) select);
@@ -660,11 +792,12 @@ private QueryInspection inspectPlainSelect(
660792
return;
661793
}
662794
for (ColumnReference reference : referencedColumns) {
795+
// Outer SELECT id FROM (subquery) t has no concrete FROM table. Nested
796+
// selects are already walked by inspectFromItem; unqualified nested
797+
// table names are refused by assertProtectedTablesAreInspectable + namesMatch.
798+
// Treating blank provenance as unresolved would also block a same-named
799+
// table in another schema (marts.customer_profiles vs public.customer_profiles).
663800
if ((reference.tableName() == null || reference.tableName().isBlank()) && defaultTableName.isBlank()) {
664-
if (!protectedObjects.isEmpty()) {
665-
inspection.unresolvedProtectedReference = true;
666-
inspection.reason = "Protected column provenance could not be established";
667-
}
668801
continue;
669802
}
670803
if (isProtectedReference(protectedObjects, reference)) {

backend/src/main/java/com/dbaagent/service/brain/analysis/ScalabilitySimulationService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,12 @@ public List<TableGrowthPrediction> getTablePredictions(String simulationId) {
504504
return predictionRepository.findByScalabilitySimulationId(simulationId);
505505
}
506506

507+
public String getConnectionId(String simulationId) {
508+
return simulationRepository.findById(simulationId)
509+
.map(ScalabilitySimulation::getConnectionId)
510+
.orElseThrow(() -> new IllegalArgumentException("Simulation not found"));
511+
}
512+
507513
/**
508514
* Get high-risk tables.
509515
*/

backend/src/main/java/com/dbaagent/service/brain/config/ConfigTuningService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,12 @@ public void cancelExperiment(String experimentId) {
723723
experimentRepository.delete(experiment);
724724
}
725725

726+
public String getConnectionId(String experimentId) {
727+
return experimentRepository.findById(experimentId)
728+
.map(TuningExperiment::getConnectionId)
729+
.orElseThrow(() -> new IllegalArgumentException("Experiment not found: " + experimentId));
730+
}
731+
726732
/**
727733
* Get experiment history.
728734
*/

backend/src/main/java/com/dbaagent/service/brain/query/PlanPatternLibraryService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ public void recordFeedback(String patternId, boolean wasSuccessful) {
194194
}
195195
}
196196

197+
public String getConnectionId(String patternId) {
198+
return patternRepository.findById(patternId)
199+
.map(PlanPattern::getConnectionId)
200+
.orElseThrow(() -> new IllegalArgumentException("Pattern not found: " + patternId));
201+
}
202+
197203
/**
198204
* Get reliable patterns for a connection.
199205
*/
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package com.dbaagent.controller;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.regex.Matcher;
11+
import java.util.regex.Pattern;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
/**
16+
* Every {@code /brain/**} endpoint must authorize the caller against the connection it
17+
* touches, not merely require a logged-in user.
18+
*
19+
* <p>This shipped with 93 of 116 endpoints unguarded. {@code SecurityConfig} only asserts
20+
* {@code .anyRequest().authenticated()} and {@code JwtAuthenticationFilter} only resolves a
21+
* principal — neither inspects a {@code connectionId}. Connections are private per user
22+
* ({@code ConnectionAccessService.resolveAccess} keys on {@code ownerUsername} plus an
23+
* explicit grant table), so an authenticated user who passed somebody else's connection id
24+
* to {@code /brain/health-scores/{id}}, {@code /brain/data-sensitivity/{id}} (which names
25+
* the PII columns), {@code /brain/cost-attribution/{id}} and ~90 others got that user's
26+
* database intelligence back.
27+
*
28+
* <p>Resource-level authorization here is opt-in per method, and the misses clustered by
29+
* when a section was written rather than by read/write semantics — the first ~15 endpoints
30+
* had it and every later "Phase" block did not. That is a defect a reviewer catches once
31+
* and a test catches forever, so it is asserted structurally: scanned as text, because the
32+
* property under test is that no endpoint method body lacks the call, whatever it does.
33+
*/
34+
class BrainControllerAuthorizationSafetyTest {
35+
36+
private static final Path CONTROLLER =
37+
Path.of("src/main/java/com/dbaagent/controller/BrainController.java");
38+
39+
/**
40+
* Matches a fully-qualified annotation as well as a bare one. A
41+
* {@code @org.springframework.web.bind.annotation.DeleteMapping} on
42+
* {@code DELETE /calibration/{connectionId}} is exactly how the one destructive
43+
* endpoint escaped the first sweep of this fix — a bare-name-only pattern silently
44+
* skips it, so the endpoint reads as "not an endpoint" rather than "unguarded".
45+
*/
46+
private static final Pattern MAPPING = Pattern.compile(
47+
"^\\s*@(?:[\\w.]*\\.)?(Get|Post|Delete|Put|Patch)Mapping\\b");
48+
49+
/** A method is authorized by a per-connection assert, or by being admin-only. */
50+
private static final Pattern AUTHORIZED = Pattern.compile(
51+
"accessControlService\\.assertCan(Read|Manage)ConnectionContent\\(|@PreAuthorize");
52+
53+
private record Endpoint(int line, String mapping, String body) {}
54+
55+
/**
56+
* Slices the controller into one entry per handler: from its mapping annotation to the
57+
* method's closing brace, which at this nesting level is a line that is exactly
58+
* {@code " }"}.
59+
*/
60+
private static List<Endpoint> endpoints() throws IOException {
61+
List<String> lines = Files.readAllLines(CONTROLLER);
62+
List<Endpoint> endpoints = new ArrayList<>();
63+
64+
for (int i = 0; i < lines.size(); i++) {
65+
Matcher matcher = MAPPING.matcher(lines.get(i));
66+
if (!matcher.find()) {
67+
continue;
68+
}
69+
StringBuilder body = new StringBuilder();
70+
int end = i;
71+
while (end < lines.size()) {
72+
body.append(lines.get(end)).append('\n');
73+
if (end > i && lines.get(end).equals(" }")) {
74+
break;
75+
}
76+
end++;
77+
}
78+
endpoints.add(new Endpoint(i + 1, lines.get(i).trim(), body.toString()));
79+
}
80+
return endpoints;
81+
}
82+
83+
@Test
84+
void everyBrainEndpointAuthorizesTheCallerAgainstTheConnection() throws IOException {
85+
List<String> offenders = new ArrayList<>();
86+
87+
for (Endpoint endpoint : endpoints()) {
88+
if (!AUTHORIZED.matcher(endpoint.body()).find()) {
89+
offenders.add(CONTROLLER + ":" + endpoint.line() + " " + endpoint.mapping());
90+
}
91+
}
92+
93+
assertThat(offenders)
94+
.as("Each of these BrainController endpoints takes a caller-supplied id and "
95+
+ "never authorizes it. Authentication is not authorization: connections "
96+
+ "are private per user, so this hands one user another user's schema, "
97+
+ "sensitivity, cost and workload intelligence. Add "
98+
+ "accessControlService.assertCanReadConnectionContent(connectionId) to "
99+
+ "reads and assertCanManageConnectionContent(connectionId) to writes, "
100+
+ "resolving the connection id first when the path carries some other id. "
101+
+ "An endpoint with no connection scope at all is admin-only (@PreAuthorize).")
102+
.isEmpty();
103+
}
104+
105+
/**
106+
* The asserts live inside each handler's {@code try}, and every handler ends with a
107+
* {@code catch (Exception)} that returns 500. Without an earlier
108+
* {@code catch (ResponseStatusException e) { throw e; }} the 403 would be swallowed and
109+
* reported as a server error — the denial would still hold, but it would look like a
110+
* bug in the feature rather than a permission boundary, and a client could not tell
111+
* "not yours" from "broken".
112+
*/
113+
@Test
114+
void authorizationFailuresPropagateAsForbiddenRatherThanServerError() throws IOException {
115+
List<String> offenders = new ArrayList<>();
116+
117+
for (Endpoint endpoint : endpoints()) {
118+
String body = endpoint.body();
119+
boolean guardedInline = body.contains("accessControlService.assertCan");
120+
if (guardedInline && !body.contains("catch (ResponseStatusException e)")) {
121+
offenders.add(CONTROLLER + ":" + endpoint.line() + " " + endpoint.mapping());
122+
}
123+
}
124+
125+
assertThat(offenders)
126+
.as("These endpoints assert access inside a try whose catch-all converts the "
127+
+ "403 into a 500. Rethrow it first: "
128+
+ "catch (ResponseStatusException e) { throw e; }")
129+
.isEmpty();
130+
}
131+
}

0 commit comments

Comments
 (0)