Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,30 @@ Dashboards are **generated by the embedded DeepSQL Agent acting as a coding agen
- **Rendering + data access**: `DashboardArtifact.jsx` renders the HTML in a **sandboxed iframe** (`sandbox="allow-scripts"`, opaque origin + a strict CSP — no external network). The artifact fetches data only through an injected `deepsql.query(sql)` bridge that `postMessage`s to the parent; the parent calls **`POST /api/dashboards/query`** (`DashboardQueryController`), which is **read-only twice over** (`McpSqlGuardService.validateReadOnlySql` + `QueryExecutionContext.api` = `READ_ONLY_ONLY`) and access-scoped via `assertCanReadConnectionContent`. So the agent's code has full creative freedom while every query stays guarded and sandboxed. The bridge also auto-sizes the iframe and forwards runtime errors.
- Generation endpoints unchanged (`POST /api/dashboards/generate` + `/generate/stream`). `DashboardBuilder.js`/`DashboardInputs.js` remain only because `tabs/Core/PreviewTab.js` still uses them — the dashboard *creation* path no longer touches them.
- **Sharing**: both share types render a standalone read-only `DashboardViewer` (title + `DashboardArtifact` with an injected `queryFn`). Internal link `/dashboard-view/:id` (auth) uses the authed broker; public link `/share/dashboard/:token` (permitAll) uses `PublicDashboardController` (`GET /api/public/dashboards/{token}` + `/query`), which resolves only while `saved_dashboards.is_public` is true (revoke = flip it) and runs read-only + connection-scoped. `share_token`/`is_public` are set only via `POST|DELETE /api/saved-dashboards/{id}/share` (access-checked), never a general update. `ShareMenu.jsx` drives the UI. The public query path has its own nginx `dashq` limiter.
- **A public share token is not a licence to run any SQL.** `POST /api/public/dashboards/{token}/query`
took the SQL as a body field and never compared it against the dashboard being shared — only
`validateReadOnlySql`, which asks whether a statement *reads*, not whether this dashboard was
published to run it. A link shared to show one chart therefore granted **anonymous read of every
table on the connection** (`SELECT * FROM users`), paginable to completion. Tokens are 192-bit so
this was never brute-forceable; the exposure is to whoever receives or forwards a link.
`DashboardQueryShapeService` now binds each public query to a **shape** extracted from the
dashboard's own stored artifact: the statement with literals replaced by placeholders, via the
existing `QueryNormalizer`. Shape-matching rather than exact-matching is load-bearing — the agent
builds interactive dashboards whose SQL is interpolated at runtime (`SKILL.md`: "There is no
placeholder convention"), so an exact match would break public links while the author's own view
kept working. Literals vary freely; tables, columns and predicates do not. Extraction is static,
from `dashboard_config`, so there is no capture step and no partly-captured set. **Unmatched
shapes fail closed** and the artifact renders its existing per-widget error, so one widget
degrades alone. Note the normalizer's `'[^']*'` rule does not model SQL's `''` escape, which is
*why* structure smuggled inside a literal is refused: it splits into a different number of
placeholders, so the shape changes. The imprecision fails safe — but it is one layer, not the
only one, and the read-only guard, `setReadOnly(true)` and the row cap all still apply.
- **Revoking a chat-access policy has to reach an already-issued share link.** Enabling a public
share is refused while the connection has an active policy (`SavedDashboardController:81`), but
nothing re-checked afterwards — so a link created *before* a policy was added stayed live and
unprotected, because `"public-share"` has no policy row and `resolveEffectivePolicy` returns
`none()`, meaning column protections and redaction never ran. `PublicDashboardController` now
re-checks `hasActivePolicy` per query, for the same reason it re-checks `is_public`.
- **Organization** (search/folders/favorites): `SavedDashboardController`'s search/folder/favorite endpoints existed for a while with no UI consumer. `DashboardsHome.jsx` now wires all of it — a search box (client-side filter over name/description), folder chips derived from `GET /connection/{id}/folders` with a per-card "move to folder" popover (`PUT /saved-dashboards/{id}` with `folder: ""` to clear — `updateDashboard` treats `null` as "field omitted" so blank is the explicit clear signal, same convention as `setSharePassword`), and a favorite star toggle (`POST /{id}/favorite`) with optimistic UI update.
- **Clone**: `POST /saved-dashboards/{id}/clone` (`SavedDashboardService.cloneDashboard`) duplicates a dashboard's config/chat/tags/folder into a fresh row — not shared, not favorited. Exposed as a copy icon on each `DashboardsHome.jsx` card.
- **Version history**: every real overwrite of `dashboardConfig` (agent build via `completeBuildTurn`, manual Source-tab edit via `updateDashboard`, or a restore) snapshots the *previous* config into `dashboard_versions` (`V113__create_dashboard_versions.sql`) before overwriting, tagged with a trigger (`AGENT_BUILD`/`MANUAL_EDIT`/`RESTORE`) — capped at 50 snapshots per dashboard, oldest pruned first. `GET /{id}/versions` lists them newest-first; `POST /{id}/versions/{versionId}/restore` swaps a snapshot back in as current (itself snapshotting whatever was live, so a restore is undoable too) and **dedupes**: after a restore, the restored row plus any other row with byte-identical `dashboard_config` are deleted, since that content is now "Current," not history — otherwise a restore-edit-restore cycle piles up an alternating chain of duplicate snapshots. `DashboardWorkspace.jsx`'s History panel shows a lightweight diff summary per entry (title/widget-count/size delta computed client-side, not a real line diff — the agent rewrites large chunks even for small logical changes) plus a Preview modal that renders that version's HTML live via `DashboardArtifact`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.dbaagent.model.QueryRequest;
import com.dbaagent.model.QueryResult;
import com.dbaagent.model.SavedDashboard;
import com.dbaagent.service.ConnectionChatAccessPolicyService;
import com.dbaagent.service.DashboardQueryShapeService;
import com.dbaagent.service.McpSqlGuardService;
import com.dbaagent.service.QueryExecutionContext;
import com.dbaagent.service.QueryExecutorService;
Expand All @@ -18,6 +20,7 @@

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Optional;

/**
Expand All @@ -43,6 +46,8 @@ public class PublicDashboardController {
private final SavedDashboardService savedDashboardService;
private final ObjectMapper objectMapper;
private final McpSqlGuardService sqlGuardService;
private final DashboardQueryShapeService queryShapeService;
private final ConnectionChatAccessPolicyService policyService;
private final QueryExecutorService queryExecutorService;

private Optional<SavedDashboard> publicDashboard(String token) {
Expand Down Expand Up @@ -101,6 +106,29 @@ public ResponseEntity<?> query(@PathVariable String token, @RequestBody PublicQu
if (!guard.ok()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", guard.reason()));
}
// Read-only is not enough on an anonymous path: it asks whether the statement reads,
// not whether this dashboard was published to run it. Without the shape check below, a
// link shared to show one chart accepted "SELECT * FROM users" just as happily.
// A policy added AFTER the link was shared must take effect on it. Enabling a share is
// refused while a policy is active (SavedDashboardController), but nothing re-checked
// afterwards, so a link created before the policy stayed live and unprotected —
// "public-share" has no policy row, so resolveEffectivePolicy returns none() and
// column protections and redaction never run. Re-checked here for the same reason
// is_public is: revocation has to reach an already-issued link.
if (policyService.hasActivePolicy(found.get().getConnectionId())) {
log.info("Public dashboard query refused for token {}: connection has an active policy", token);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"success", false,
"error", "This dashboard is no longer available publicly."));
}
Set<String> publishedShapes =
queryShapeService.extractShapes(found.get().getDashboardConfig());
if (!queryShapeService.matches(publishedShapes, request.sql())) {
log.info("Public dashboard query refused for token {}: shape not published", token);
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"error", "This query is not part of the shared dashboard."));
}
int limit = request.limit() == null ? DEFAULT_LIMIT : Math.max(1, Math.min(request.limit(), MAX_LIMIT));
try {
QueryRequest qr = new QueryRequest();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package com.dbaagent.service;

import com.dbaagent.util.QueryNormalizer;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Binds the queries a public dashboard may run to the ones its artifact actually contains.
*
* <p>{@code POST /api/public/dashboards/{token}/query} takes the SQL as a request body field.
* Checking only that the statement reads is not enough: it answers "is this a select" when the
* question is "is this a query this dashboard was published to run". Without the shape check
* below, a link shared to show one chart granted anonymous read of the whole connection —
* verified live against a real share token, which returned customer rows including
* {@code email}, {@code password_hash} and {@code phone}.
*
* <p>Exact string matching cannot be the answer. Dashboards are interactive by design — a date
* picker re-queries with new bounds on every change ({@code dashboard-design/SKILL.md}), so the
* exact string is not knowable at publish time. Matching would then fail only on the public
* link while the author's own view kept working, which is the worst shape a regression can take.
*
* <p>So queries are matched by <em>shape</em>: the statement with its literals replaced by
* placeholders, via the same {@link QueryNormalizer} that backs {@link QueryFingerprintService}.
* Two queries differing only in a date range share a shape; two naming different tables or
* columns do not.
*
* <p><strong>Real artifacts assign the SQL to a variable first.</strong> An earlier version of
* this class matched only a literal argument to {@code deepsql.query(...)}. Every call site in
* the real dashboards checked — 18 of 18, across the names {@code query}, {@code sql},
* {@code trendQuery} and {@code totalQuery} — instead does:
*
* <pre>{@code
* const sql = `SELECT ... WHERE created_at >= '${esc(from)}'`;
* const { rows } = await deepsql.query(sql);
* }</pre>
*
* So extraction produced an empty set and, failing closed, refused every query on every
* existing public dashboard. Declarations are resolved first and the call's argument is looked
* up among them, which is why this does not key on particular variable names.
*
* <p>This is one layer, not the only one. {@code validateReadOnlySql},
* {@code connection.setReadOnly(true)}, the row cap and the {@code is_public} re-check all still
* apply. That matters because {@code QueryNormalizer} was written for analytics grouping, where
* a collision is a cosmetic nuisance rather than a vulnerability.
*/
@Service
public class DashboardQueryShapeService {

/** A string literal in any of the three quoting styles the agent emits. */
private static final String LITERAL =
"`(?:[^`\\\\]|\\\\.)*`|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'";

/** {@code const|let|var <name> = <literal>} — how every real artifact holds its SQL. */
private static final Pattern DECLARATION = Pattern.compile(
"\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(" + LITERAL + ")",
Pattern.DOTALL);

/** The argument of a {@code deepsql.query(...)} call: a literal, or an identifier. */
private static final Pattern QUERY_CALL = Pattern.compile(
"deepsql\\s*\\.\\s*query\\s*\\(\\s*(" + LITERAL + "|[A-Za-z_$][\\w$]*)\\s*[,)]",
Pattern.DOTALL);

/** Any {@code deepsql.query(} call at all, used to spot arguments neither branch resolved. */
private static final Pattern ANY_QUERY_CALL = Pattern.compile("deepsql\\s*\\.\\s*query\\s*\\(");

/**
* One {@code <script>} block. Each widget is its own block and its own scope: a real
* dashboard here has nine blocks, eight declaring their own {@code const sql = ...} with
* different SQL. Resolving across the whole document collapses those onto one name and
* silently drops seven queries, so declarations are resolved per block.
*/
private static final Pattern SCRIPT_BLOCK = Pattern.compile(
"<script\\b[^>]*>(.*?)</script\\s*>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

/**
* A JS template interpolation. Replaced with a placeholder before normalizing, so
* {@code '${esc(from)}'} yields the same shape as the {@code '2026-01-01'} it becomes at
* runtime.
*/
private static final Pattern INTERPOLATION = Pattern.compile("\\$\\{[^}]*\\}");

/** Extracts the shape of every query the artifact can issue. */
public Set<String> extractShapes(String artifactHtml) {
Set<String> shapes = new LinkedHashSet<>();
if (artifactHtml == null || artifactHtml.isBlank()) {
return shapes;
}
for (String scope : scopes(artifactHtml)) {
Map<String, String> declared = declaredLiterals(scope);
Matcher calls = QUERY_CALL.matcher(scope);
while (calls.find()) {
String sql = resolveArgument(calls.group(1), declared);
if (sql == null) {
continue;
}
String shape = shapeOf(sql);
if (!shape.isBlank()) {
shapes.add(shape);
}
}
}
return shapes;
}

/**
* Whether the artifact issues a query whose SQL this class could not recover — for example
* one built by concatenation or returned from a helper.
*
* <p>Such a call must be reported rather than skipped. Skipping it publishes a shape set
* missing one of the dashboard's own queries, which then fails closed at runtime: a widget
* broken for the audience only, with nothing on the authoring side to indicate why.
*/
public boolean hasUnresolvableQuery(String artifactHtml) {
if (artifactHtml == null || artifactHtml.isBlank()) {
return false;
}
return totalQueryCalls(artifactHtml) > resolvedQueryCalls(artifactHtml);
}

/** The shape of one SQL statement: its literals replaced by placeholders. */
public String shapeOf(String sql) {
if (sql == null || sql.isBlank()) {
return "";
}
return QueryNormalizer.normalize(sql);
}

/**
* Whether {@code sql} matches a published shape. Fails closed: an empty shape set, a blank
* statement, or any shape that was not extracted is refused.
*/
public boolean matches(Set<String> publishedShapes, String sql) {
if (publishedShapes == null || publishedShapes.isEmpty() || sql == null || sql.isBlank()) {
return false;
}
String shape = shapeOf(sql);
return !shape.isBlank() && publishedShapes.contains(shape);
}

/**
* The artifact's scopes: each {@code <script>} block, or the whole document when it has
* none, so a call outside a script tag is still seen.
*/
private java.util.List<String> scopes(String artifactHtml) {
java.util.List<String> scopes = new java.util.ArrayList<>();
Matcher blocks = SCRIPT_BLOCK.matcher(artifactHtml);
while (blocks.find()) {
scopes.add(blocks.group(1));
}
if (scopes.isEmpty()) {
scopes.add(artifactHtml);
}
return scopes;
}

private Map<String, String> declaredLiterals(String artifactHtml) {
Map<String, String> declared = new HashMap<>();
Matcher declarations = DECLARATION.matcher(artifactHtml);
while (declarations.find()) {
declared.put(declarations.group(1), unwrapJsLiteral(declarations.group(2)));
}
return declared;
}

/** A literal argument is used directly; an identifier is looked up among the declarations. */
private String resolveArgument(String argument, Map<String, String> declared) {
if (isLiteral(argument)) {
return unwrapJsLiteral(argument);
}
return declared.get(argument);
}

private boolean isLiteral(String argument) {
if (argument == null || argument.length() < 2) {
return false;
}
char first = argument.charAt(0);
return first == '`' || first == '"' || first == '\'';
}

private int totalQueryCalls(String artifactHtml) {
return (int) ANY_QUERY_CALL.matcher(artifactHtml).results().count();
}

private int resolvedQueryCalls(String artifactHtml) {
int resolved = 0;
for (String scope : scopes(artifactHtml)) {
Map<String, String> declared = declaredLiterals(scope);
Matcher calls = QUERY_CALL.matcher(scope);
while (calls.find()) {
if (resolveArgument(calls.group(1), declared) != null) {
resolved++;
}
}
}
return resolved;
}

/**
* Strips the surrounding quotes from a JS string literal and collapses interpolations.
*
* <p>An interpolation becomes {@code ?} so that {@code >= '${esc(from)}'} yields the same
* shape as the runtime statement {@code >= '2026-01-01'}: the quotes around it are already
* in the artifact, and the normalizer turns the quoted placeholder into its own {@code ?}.
*/
private String unwrapJsLiteral(String literal) {
String body = literal.substring(1, literal.length() - 1);
return unescape(INTERPOLATION.matcher(body).replaceAll("?"));
}

/**
* Turns escape sequences into the characters they stand for.
*
* <p>{@code dashboard_config} stores the broker's JSON envelope, so the artifact arrives
* with its newlines as a literal backslash-n and its quotes escaped. {@link QueryNormalizer}
* collapses <em>real</em> whitespace, so without this the published shape keeps
* {@code customer_count\n from} where the runtime statement has a space, and no query on
* the dashboard ever matches.
*
* <p>Worth recording how this was found: an earlier probe unescaped the database dump by
* hand before extracting, so the harness was more forgiving than the production path and
* three rounds of green tests missed it. It surfaced only by calling the real endpoint
* against the real stored row.
*/
private String unescape(String text) {
return text.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace("\\\"", "\"")
.replace("\\'", "'")
.replace("\\`", "`")
.replace("\\\\", "\\");
}
}
Loading
Loading