feat: pin a default connection; fix Editor misdiagnosing malformed SQL as a permissions error - #107
Merged
Conversation
Adds a pin toggle in Manage Connections and in the sidebar connection
switcher. A pinned connection is the one DeepSQL opens on every load.
The pin is per user, not a flag on the connection. A connection can be
shared through connection_access_grant, so a column on database_connection
would let one person's choice decide what everyone else opens on — and
shared connections are canManageConfig=false for their recipients, so
exactly the people who most want a default could not set one. One pin per
user, enforced by a unique constraint on connection_pin.username; pinning a
second connection moves the row rather than adding one.
PUT|DELETE /connections/{id}/pin are gated on assertCanUseConnection, not
assertCanManageConnectionConfig: choosing where you land is a preference,
not a change to the connection. GET /connections carries the pinned flag per
caller, so no surface needs a second request, and deleteConnection clears
every pin on the connection alongside its grants.
The pin has to beat an already-selected connection, not just an empty one.
useDashboardStore persists connectionId, so after a reload something is
always selected and the existing auto-select never ran. useConnectionManager
now applies the pin once per page load, tracked at module scope rather than
in a ref — the hook is called from a dozen sections, and a per-instance
guard would let a later-mounted section yank the user back to the pin after
they deliberately switched.
The sidebar toggle is there because Manage Connections needs
MANAGE_CONNECTIONS to open at all; without it a Developer or Data Engineer,
who typically holds exactly one granted connection, would have no way to set
a default.
ConnectionScopedAuthorizationSafetyTest now flags GET /connections, because
the handler resolves the caller's pinned connection id and the scanner
matches (?i)connection_?id anywhere in a handler body. That endpoint takes
no arguments at all, so it is exempted in AUTHORIZED_ELSEWHERE and
connectionListingTakesNoCallerSuppliedId re-derives the claim, so the
exemption cannot rot into cover for a real gap. The scanner was not
weakened and no meaningless assert was added.
Verified against a live backend on a throwaway stack: ddl-auto creates
connection_pin with its unique index; pinning a second connection flips the
first; unpinning a non-pinned connection leaves the real pin alone; deleting
a connection clears its pin; a DEVELOPER with no grant gets 403 (not 500);
a DEVELOPER holding only a grant pins successfully and their pin does not
appear on the admin's list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ons denial
A user pasted a SELECT that still carried the double quotes it had in source
code ("select h.id, ...) and was told "Only admins can execute DDL or DML
from the SQL Editor". That reads as a permissions problem and sent people
looking for a role fix. The statement is neither DDL nor DML — with an
unclosed double quote the whole thing is one quoted identifier, so it is not
valid SQL at all.
Two keyword heuristics disagreed and the disagreement was resolved as
"mutation". QueryNormalizer.detectQueryType sanitizes a prefix away and
answers SELECT; the provider's isReadOnlyQuery strips only comments, still
sees the leading quote, and answers false. mutating was computed as
(!readOnly && type != UNKNOWN), so a statement classifyStatement had itself
labelled SELECT became a mutation.
classifyStatement now records that the parser rejected the statement and,
when the detected verb is read-only and no hidden write was found, returns
notParseable. enforce throws STATEMENT_NOT_PARSEABLE ahead of both the
read-only branch and the mutation-confirmation branch, with a message naming
the likely cause.
The statement is still blocked, admins included — only the diagnosis
changed. An admin is deliberately not offered a confirmation prompt for a
statement nothing managed to classify, since confirming past the guard is
the one way this could become a bypass. The reclassification is gated on
isReadOnlyVerb(queryType) and hiddenWrite == null, which is what keeps a
write the parser happens to reject from being excused as a typo: an
unparseable DELETE, and a malformed data-modifying CTE, both keep their
mutation handling, and both are covered by tests.
QueryExecutionPolicyServiceTest: 49 tests pass, including every pre-existing
guard case. Full backend suite diffed against pristine HEAD — failure sets
identical, no regressions. The MCP guard already reported this case honestly
("Only read-only SQL is allowed ...") and was left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
venkateshsakamuri-lab
requested review from
a team and
geekypunk
as code owners
September 9, 2026 06:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent changes, one commit each.
1.
feat:pin a connection as your per-user defaultRequested feature. A pin toggle in Manage Connections and in the sidebar connection switcher; a pinned connection is the one DeepSQL opens on every load.
The pin is per user, not a flag on the connection. A connection can be shared through
connection_access_grant, so a column ondatabase_connectionwould let one person's choice decide what everyone else opens on — and shared connections arecanManageConfig=falsefor their recipients, so exactly the people who most want a default could not set one. Newconnection_pintable, one row per user, unique constraint onusername; pinning a second connection moves the row rather than adding one.PUT|DELETE /connections/{id}/pinare gated onassertCanUseConnection, notassertCanManageConnectionConfig— choosing where you land is a preference, not a change to the connection.GET /connectionscarriespinnedper caller, so no surface needs a second request;deleteConnectionclears every pin on the connection alongside its grants.The pin has to beat an already-selected connection, not just an empty one.
useDashboardStorepersistsconnectionId, so after a reload something is always selected and the existing auto-select never ran.useConnectionManagernow applies the pin once per page load, tracked at module scope rather than in a ref — the hook is called from a dozen sections, and a per-instance guard would let a later-mounted section yank the user back to the pin after they deliberately switched. Switching mid-session still sticks.The sidebar toggle exists because Manage Connections needs
MANAGE_CONNECTIONSto open at all; without it a Developer or Data Engineer, who typically holds exactly one granted connection, would have no way to set a default.One thing reviewers should look at
ConnectionScopedAuthorizationSafetyTestnow flagsGET /connections, because the handler resolves the caller's pinned connection id and the scanner matches(?i)connection_?idanywhere in a handler body. That endpoint takes no arguments at all — it returns whatevergetConnectionsForUser(username, isAdmin)gives. It is exempted inAUTHORIZED_ELSEWHERE, and a newconnectionListingTakesNoCallerSuppliedIdtest re-derives the claim so the exemption cannot rot into cover for a real gap. The scanner was not weakened and no meaningless assert was added.Verified against a live backend
On a throwaway Postgres/backend stack, not inferred:
ddl-autocreatesconnection_pinwith its unique index on bootfalsecanManageConfig: false) pins successfully, and their pin does not appear on the admin's list2.
fix:Editor reported malformed SQL as a permissions denialReported from the field. A user pasted a SELECT that still carried the double quotes it had in source code (
"select h.id, ...) and was told "Only admins can execute DDL or DML from the SQL Editor" — which reads as a permissions problem and sent people looking for a role fix.The statement is neither DDL nor DML. With an unclosed
"the whole thing is one quoted identifier, so it is not valid SQL at all.Two keyword heuristics disagreed, and the disagreement was resolved as "mutation":
QueryNormalizer.detectQueryTypesanitizes a prefix away and answersSELECT; the provider'sisReadOnlyQuerystrips only comments, still sees the leading quote, and answers false.mutating = !readOnly && type != UNKNOWNthen labelled a SELECT a mutation.classifyStatementnow records that the parser rejected the statement and, when the detected verb is read-only and no hidden write was found, returnsnotParseable;enforcethrowsSTATEMENT_NOT_PARSEABLEahead of both the read-only and confirmation branches, with a message naming the likely cause.This does not weaken the guard
The statement is still blocked, admins included — only the diagnosis changed. An admin is deliberately not offered a confirmation prompt for a statement nothing managed to classify, since confirming past the guard is the one way this could become a bypass.
The reclassification is gated on
isReadOnlyVerb(queryType)andhiddenWrite == null. Both halves are tested:anUnparseableWriteIsStillTreatedAsAMutation—DELETE FROM hotel WHERE (((still returnsEDITOR_MUTATION_FORBIDDENaMalformedDataModifyingCteIsStillBlockedAsAWrite— a brokenWITH x AS (DELETE ...)is still a blocked write, not a reported typoThe MCP guard already reported this case honestly ("Only read-only SQL is allowed …") and was left alone.
Testing
QueryExecutionPolicyServiceTest: 49 pass (43 before, 6 added), including every pre-existing guard case — data-modifying CTEs,SELECT INTO,DROPblocking, EXPLAIN-wrapped writes.npm run buildclean; 0 lint errors in changed files.🤖 Generated with Claude Code