Skip to content

Commit a20e71a

Browse files
Merge branch 'main' into feat/workspaces-and-roles
2 parents 81f332c + 93553f4 commit a20e71a

38 files changed

Lines changed: 658 additions & 77 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ for product tags (`vMAJOR.MINOR.PATCH`).
88

99
Product releases follow a **weekly cadence** (Saturday 09:00 America/Los_Angeles). See `docs/oss-ux/RELEASE.md`.
1010

11+
## [1.3.0] — 2026-08-24
12+
13+
### Added
14+
15+
- **DeepSQL Desktop** — first-ship Electron thin client (`desktop/`) with direct TLS and SSH-tunnel transports, connection profiles, OS keychain secrets, and native chrome (#73).
16+
- Desktop release workflow (`.github/workflows/desktop-release.yml`) for macOS / Windows / Linux installers on `desktop-v*` tags.
17+
- Enforceable Agent brain-note proposals with non-blocking save bubbles (#75).
18+
- Schema documentation dedupe / `CODE_DERIVED` compatibility initializers (`V116`) (#74, #77).
19+
20+
### Fixed
21+
22+
- Cross-user MCP credential leak in the Agent tab — provisioner no longer last-writer-wins across Hermes profiles; MCP tokens bind to declared client identity (#78).
23+
- View as Agent enforces the target user’s data policy on new Agent threads (#71).
24+
- Chat schema allowlist enumerates the whole statement (#70).
25+
- Brain endpoints require connection content authorization (#72).
26+
- Review queue approvals: stale pending counts, bulk `failures[]`, approval path unwedge (#74, #77).
27+
- Editor CSV export bounded; concurrent-run guards and cancel audit (#76).
28+
29+
### Changed
30+
31+
- Documented CORS loopback wildcards required for Desktop SSH tunnels (`CORS_ALLOWED_ORIGINS`).
32+
- `@deepsql/mcp``0.27.1`.
33+
- DeepSQL Desktop package → `1.0.0` (cut installers with `desktop-v1.0.0`).
34+
35+
### Notes
36+
37+
- Open follow-up: dashboard workspaces + custom roles (#80) intentionally not in this cut.
38+
1139
## [1.2.0] — 2026-08-19
1240

1341
### Added
@@ -82,6 +110,7 @@ First public OSS release.
82110
- Residual high-severity items tracked in `docs/oss-ux/OSS_SECURITY_REVIEW.md` (IDOR sweep, SET preamble allowlist, SSRF hardening, share-password defaults) are deferred past this cut.
83111
- Primary distribution path remains `docker compose up --build` (no pre-built container registry in this release).
84112

113+
[1.3.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.3.0
85114
[1.2.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.2.0
86115
[1.1.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.1.0
87116
[1.0.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.0.0

CLAUDE.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,8 @@ their native runners. See `desktop/README.md` for the full picture.
245245
## MCP Server
246246

247247
- `mcp/deepsql-phase1-server.js` implements a Phase 1 stdio MCP server for internal rollout.
248-
- It exposes read-only tools only: listing connections, fetching schema/objects, asking DeepSQL questions, executing read-only SQL, and running EXPLAIN without ANALYZE.
249-
- It wraps existing backend APIs, so it reuses DeepSQL chat orchestration, RAG, connection management, and guardrails instead of exposing raw DB credentials.
250-
- Read-only enforcement is applied in `mcp/deepsql-phase1-lib.js` before calling backend execution endpoints.
248+
- Schema/retrieval tools stay read-only. `execute_sql` is role-gated: developers stay read-only; admins can run DML and non-destructive DDL (`CREATE`, `ALTER`) with the same two-step confirmation as the SQL Editor. `DROP` and `TRUNCATE` stay blocked on MCP even when confirmed.
249+
- It wraps existing backend APIs, so it reuses DeepSQL chat orchestration, RAG, connection management, and `QueryExecutionPolicyService` instead of exposing raw DB credentials.
251250
- Client config examples live in `.cursor/mcp.json` and `mcp/claude_desktop_config.example.json`.
252251
- Usage and env vars are documented in `docs/root/MCP_PHASE1.md`.
253252

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ control except the prompts you send to the endpoint you chose.
1515
📄 **[Read the whitepaper](https://deepsql.ai/whitepaper)** — the architecture and the
1616
reasoning behind it.
1717

18+
📦 **Latest release: [v1.3.0](https://github.com/DeepSQLAI/deepsql/releases/tag/v1.3.0)**
19+
DeepSQL Desktop first ship + Agent/Brain/Editor hardening. Notes:
20+
[`docs/releases/RELEASE_NOTES-v1.3.0.md`](docs/releases/RELEASE_NOTES-v1.3.0.md).
21+
1822
---
1923

2024
## Quick start

agent/SOUL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ After the answer you may offer **one short follow-up question** (a single line)
1616

1717
4. **Table-qualify every column** in generated SQL (`table.column`). Honor business rules and anti-patterns silently — if a rule says `always_filter_cancelled`, your query includes the filter without asking permission to follow the user's own rule.
1818

19-
5. **Read-only by default.** Developers cannot mutate; admins can with a **two-step confirmation**. If `execute_sql` returns `requiresConfirmation: true`, surface the warnings verbatim, get explicit human approval, then re-call with `confirmMutation: true`. NEVER auto-confirm — that defeats the safety gate. Never try to work around a 403/`EDITOR_MUTATION_FORBIDDEN`; surface it.
19+
5. **Read-only by default.** Developers cannot mutate; admins can run DML and non-destructive DDL (`CREATE`, `ALTER`) with a **two-step confirmation**. `DROP` and `TRUNCATE` cannot be run via `execute_sql` — they stay blocked even after confirmation. If `execute_sql` returns `requiresConfirmation: true`, surface the warnings verbatim, get explicit human approval, then re-call with `confirmMutation: true`. NEVER auto-confirm — that defeats the safety gate. Never try to work around a 403/`EDITOR_MUTATION_FORBIDDEN` or an `UNSAFE_MUTATION_BLOCKED` DROP/TRUNCATE; surface it.
2020

2121
6. **One execution tool, one analysis tool.** Use `execute_sql` to run SQL; use `analyze_query_plan` for plans. Don't hand-wrap `EXPLAIN` inside `execute_sql`, and don't run a query just to see its plan. `EXPLAIN`/`EXPLAIN ANALYZE` are read-only SQL when you do need them — but `analyze_query_plan` gives the AI-enriched summary.
2222

backend/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
<groupId>com.dbaagent</groupId>
1616
<artifactId>dba-agent-backend</artifactId>
17-
<version>1.2.0</version>
17+
<version>1.3.0</version>
1818
<name>DBA Agent Backend</name>
1919
<description>Backend service for DBA Agent with secure credential storage and schema analysis</description>
2020

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.dbaagent.service.ClientContext;
1111
import com.dbaagent.service.CredentialService;
1212
import com.dbaagent.service.ExplainPlanService;
13+
import com.dbaagent.service.McpTokenService;
1314
import com.dbaagent.service.QueryExecutionContext;
1415
import com.dbaagent.service.QueryExecutionPolicyException;
1516
import com.dbaagent.service.QueryExecutionPolicyService;
@@ -19,6 +20,7 @@
1920
import lombok.Data;
2021
import lombok.RequiredArgsConstructor;
2122
import lombok.extern.slf4j.Slf4j;
23+
import org.springframework.http.HttpHeaders;
2224
import org.springframework.http.HttpStatus;
2325
import org.springframework.http.ResponseEntity;
2426
import org.springframework.jdbc.BadSqlGrammarException;
@@ -38,9 +40,10 @@
3840
* 1. `useAnalyze=true` actually runs the underlying statement inside
3941
* EXPLAIN ANALYZE — so for any mutating statement, that's a real
4042
* database write. We route those through QueryExecutionPolicyService
41-
* with `QueryExecutionContext.editor(...)` so the same role/WHERE/
42-
* confirmation gates that protect /api/connections/{id}/query
43-
* protect this path too.
43+
* with `QueryExecutionContext.forSqlSurface(...)` so MCP bearers
44+
* keep the MCP DROP/TRUNCATE block and Editor JWT callers keep the
45+
* Editor DROP TABLE block. Role, WHERE, and confirmation gates that
46+
* protect /api/connections/{id}/query protect this path too.
4447
*
4548
* 2. Every call — success, blocked, or failed — emits a SecurityEvent so
4649
* audit dashboards can see CLI/MCP/Editor traffic with one filter.
@@ -71,23 +74,27 @@ public ResponseEntity<?> analyzeQuery(
7174
) {
7275
ClientContext client = ClientContext.fromRequest(httpRequest);
7376
String connectionId = request.getConnectionId();
74-
QueryRequest auditQueryRequest = buildAuditQueryRequest(request);
77+
QueryRequest auditQueryRequest = buildAuditQueryRequest(request, httpRequest);
7578
ConnectionRequest connectionRequest = null;
7679

7780
try {
7881
accessControlService.assertCanUseChatEditor(connectionId);
7982
log.info("EXPLAIN analysis requested for connection: {} (useAnalyze={})", connectionId, request.isUseAnalyze());
8083

8184
// ANALYZE actually executes the SQL. Route the underlying
82-
// statement through the same policy gate the SQL Editor uses so
83-
// a developer can't bypass the mutation guard by sending
84-
// useAnalyze=true with `DELETE FROM users`.
85+
// statement through the same policy gate /connections/{id}/query
86+
// uses so a developer can't bypass the mutation guard by sending
87+
// useAnalyze=true with `DELETE FROM users`, and MCP callers keep
88+
// the DROP/TRUNCATE block.
8589
if (request.isUseAnalyze()) {
8690
connectionRequest = credentialService.getDecryptedConnection(connectionId);
8791
String dbType = providerRegistry.getCanonicalName(connectionRequest.getDbType());
8892
queryExecutionPolicyService.enforce(
8993
auditQueryRequest,
90-
QueryExecutionContext.editor(
94+
QueryExecutionContext.forSqlSurface(
95+
McpTokenService.isMcpAuthorizationHeader(
96+
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
97+
),
9198
accessControlService.getCurrentUsername(),
9299
accessControlService.isCurrentUserAdmin(),
93100
Boolean.TRUE.equals(request.getMutationConfirmed())
@@ -161,10 +168,13 @@ public ResponseEntity<?> analyzeQuery(
161168
* Carries the user's mutationConfirmed flag through so admin-confirmed
162169
* ANALYZE runs aren't stuck on the confirmation gate.
163170
*/
164-
private QueryRequest buildAuditQueryRequest(ExplainRequest request) {
171+
private QueryRequest buildAuditQueryRequest(ExplainRequest request, HttpServletRequest httpRequest) {
165172
QueryRequest qr = new QueryRequest();
166173
qr.setQuery(request.getQuery());
167-
qr.setExecutionOrigin(QueryExecutionOrigin.EDITOR);
174+
boolean mcpBearer = McpTokenService.isMcpAuthorizationHeader(
175+
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
176+
);
177+
qr.setExecutionOrigin(mcpBearer ? QueryExecutionOrigin.MCP : QueryExecutionOrigin.EDITOR);
168178
qr.setMutationConfirmed(Boolean.TRUE.equals(request.getMutationConfirmed()));
169179
return qr;
170180
}

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

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,12 @@ public ResponseEntity<Map<String, Object>> executeQuery(
191191
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
192192
}
193193
accessControlService.assertCanUseChatEditor(connectionId);
194-
queryRequest.setExecutionOrigin(QueryExecutionOrigin.EDITOR);
194+
boolean mcpBearer = McpTokenService.isMcpAuthorizationHeader(
195+
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
196+
);
197+
queryRequest.setExecutionOrigin(
198+
mcpBearer ? QueryExecutionOrigin.MCP : QueryExecutionOrigin.EDITOR
199+
);
195200
connectionRequest = credentialService.getDecryptedConnection(connectionId);
196201

197202
QueryResult result = queryExecutorService.executeQuery(
@@ -438,25 +443,14 @@ public ResponseEntity<Map<String, Object>> getTableStats(
438443
}
439444

440445
private QueryExecutionContext queryExecutionContext(QueryRequest queryRequest, HttpServletRequest httpRequest) {
441-
String username = accessControlService.getCurrentUsername();
442-
boolean admin = accessControlService.isCurrentUserAdmin();
443-
if (isMcpBearer(httpRequest)) {
444-
return QueryExecutionContext.mcp(username, admin);
445-
}
446-
return QueryExecutionContext.editor(
447-
username,
448-
admin,
446+
return QueryExecutionContext.forSqlSurface(
447+
McpTokenService.isMcpAuthorizationHeader(httpRequest.getHeader(HttpHeaders.AUTHORIZATION)),
448+
accessControlService.getCurrentUsername(),
449+
accessControlService.isCurrentUserAdmin(),
449450
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
450451
);
451452
}
452453

453-
private boolean isMcpBearer(HttpServletRequest httpRequest) {
454-
String authorization = httpRequest.getHeader(HttpHeaders.AUTHORIZATION);
455-
return authorization != null
456-
&& authorization.startsWith("Bearer ")
457-
&& authorization.substring(7).startsWith(McpTokenService.TOKEN_PREFIX);
458-
}
459-
460454
private SchemaMetadata scopedSchema(String connectionId, SchemaMetadata schema) {
461455
return userDataAccessPolicyService.filterSchemaMetadata(
462456
connectionId,

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.dbaagent.service.SystemConfigService;
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Value;
910
import org.springframework.http.ResponseEntity;
1011
import org.springframework.web.bind.annotation.*;
1112
import org.springframework.web.client.RestClient;
@@ -34,6 +35,25 @@ public class SetupController {
3435
private final CredentialRepository credentialRepository;
3536
private final LlmConfigResolver llmConfigResolver;
3637

38+
/**
39+
* Mirrors {@code security.google.enabled}. Surfaced on the public status
40+
* endpoint so the login page can decide whether to offer Google sign-in —
41+
* it is otherwise unauthenticated and has no way to know the server was
42+
* configured for SSO. Deliberately NOT final: {@code @RequiredArgsConstructor}
43+
* would pull a final field into the constructor and Spring has no bean to
44+
* satisfy it.
45+
*/
46+
@Value("${security.google.enabled:false}")
47+
private boolean googleEnabled;
48+
49+
/**
50+
* Mirrors {@code security.password.enabled}. Lets the login page hide the
51+
* email/password form on SSO-only installs instead of rendering a form that
52+
* always fails. Same non-final reasoning as above.
53+
*/
54+
@Value("${security.password.enabled:true}")
55+
private boolean passwordLoginEnabled;
56+
3757
// ── GET /setup/status ─────────────────────────────────────────────────────
3858

3959
/** Returns setup completion state. Public endpoint — no auth required. */
@@ -53,7 +73,9 @@ public SetupStatusResponse getStatus() {
5373
setupComplete,
5474
hasOrgInfo,
5575
hasConnections,
56-
hasLlmConfig
76+
hasLlmConfig,
77+
googleEnabled,
78+
passwordLoginEnabled
5779
);
5880
}
5981

@@ -278,7 +300,11 @@ public record SetupStatusResponse(
278300
boolean setupComplete,
279301
boolean hasOrganizationInfo,
280302
boolean hasConnections,
281-
boolean hasLlmConfig
303+
boolean hasLlmConfig,
304+
/** Whether Google Workspace SSO is configured; drives the login page's SSO button. */
305+
boolean googleEnabled,
306+
/** Whether email+password sign-in is accepted; false hides the password form. */
307+
boolean passwordLoginEnabled
282308
) {}
283309

284310
public record InitializeRequest(String orgName, String adminUsername, String adminEmail, String adminPassword) {}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@ public boolean looksLikeMcpToken(String rawToken) {
174174
return rawToken != null && rawToken.startsWith(TOKEN_PREFIX);
175175
}
176176

177+
/**
178+
* True when the Authorization header is a DeepSQL MCP bearer token
179+
* ({@code Bearer dsql_mcp_…}). JWT and other Bearer schemes return false.
180+
*/
181+
public static boolean isMcpAuthorizationHeader(String authorization) {
182+
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
183+
return false;
184+
}
185+
return authorization.substring(7).startsWith(TOKEN_PREFIX);
186+
}
187+
177188
private boolean isExpired(McpToken token) {
178189
return token.getExpiresAt() != null && !token.getExpiresAt().isAfter(LocalDateTime.now());
179190
}

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import com.dbaagent.repository.*;
55
import com.dbaagent.security.EncryptionService;
66
import com.dbaagent.util.SecurityHashUtil;
7+
import jakarta.annotation.PostConstruct;
78
import lombok.Builder;
89
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
911
import org.springframework.beans.factory.annotation.Value;
1012
import org.springframework.http.HttpStatus;
1113
import org.springframework.http.MediaType;
@@ -25,6 +27,7 @@
2527

2628
@Service
2729
@RequiredArgsConstructor
30+
@Slf4j
2831
public class PasswordlessAuthService {
2932
private static final SecureRandom RANDOM = new SecureRandom();
3033

@@ -72,6 +75,32 @@ public class PasswordlessAuthService {
7275
@Value("${security.google.enabled:false}")
7376
private boolean googleEnabled;
7477

78+
/**
79+
* Whether email + password sign-in is accepted at all.
80+
*
81+
* <p>Defaults to {@code true} so existing installs are unaffected. Set it to
82+
* false on deployments that front DeepSQL with Google Workspace SSO: enabling
83+
* SSO does NOT by itself close the password path, so without this flag
84+
* {@code /auth/login} stays open to every local account even when every human
85+
* signs in through Google.
86+
*
87+
* <p>Turning this off while {@code security.google.enabled} is also off leaves
88+
* no way to sign in — {@link #warnIfNoAuthMethodEnabled()} shouts about that at
89+
* startup rather than letting it be discovered at the login screen.
90+
*/
91+
@Value("${security.password.enabled:true}")
92+
private boolean passwordLoginEnabled;
93+
94+
@PostConstruct
95+
void warnIfNoAuthMethodEnabled() {
96+
if (!passwordLoginEnabled && !googleEnabled) {
97+
log.error("security.password.enabled=false AND security.google.enabled=false — "
98+
+ "no sign-in method is available and nobody can log in. Enable one of them.");
99+
} else if (!passwordLoginEnabled) {
100+
log.info("Password sign-in is DISABLED (security.password.enabled=false); Google SSO only.");
101+
}
102+
}
103+
75104
@Value("${security.google.client-id:}")
76105
private String googleClientId;
77106

@@ -87,6 +116,23 @@ public class PasswordlessAuthService {
87116
@Transactional
88117
public AuthFlowResult loginWithPassword(String email, String password, String clientIp, String userAgent, String requestId) {
89118
String normalizedEmail = normalizeEmail(email);
119+
120+
// Checked before the rate limiter and before any credential comparison:
121+
// when the password path is closed there is nothing to rate-limit and no
122+
// secret to compare, and we must not leak whether the account exists.
123+
if (!passwordLoginEnabled) {
124+
securityEventService.log(SecurityEventService.EventRequest.builder()
125+
.eventType(SecurityEventType.PASSWORD_LOGIN_FAILURE)
126+
.outcome(SecurityEventOutcome.FAILURE)
127+
.email(normalizedEmail)
128+
.clientIp(clientIp)
129+
.userAgent(userAgent)
130+
.requestId(requestId)
131+
.metadata(Map.of("reason", "password_login_disabled"))
132+
.build());
133+
return AuthFlowResult.invalid("Password sign-in is disabled. Please sign in with Google.");
134+
}
135+
90136
if (rateLimitEnabled) enforcePasswordRateLimit(normalizedEmail, clientIp);
91137

92138
User user = normalizedEmail == null ? null : userRepository.findByEmailIgnoreCase(normalizedEmail).orElse(null);

0 commit comments

Comments
 (0)