Repository navigation
Apply SQL attribute permissions to every column a statement references - #3804
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors and strengthens SQL attribute-level read permission checks across all SQL engines (legacy, auto, and new). It introduces a shared column resolution scope helper (columnScope.ts) used by both the query binder and the authorization bucket (sqlStatementBucket) to ensure columns are authorized against the exact tables they are read from. The AST walking mechanism is updated from RecursiveIterator to custom iterative generators (astNodes and columnNodes) to prevent depth-limit bypasses and handle shared subquery nodes. Additionally, the PR adds strict checks to refuse statements with unresolved column qualifiers, whole-row accessors (like _ or JavaScript expressions), and unauthorized reads in UPDATE/DELETE statements. Comprehensive unit and integration tests are added to verify these security boundaries. There are no review comments to evaluate, and I have no feedback to provide.
e99f454 to
e1dc9e9
Compare
|
Comment for harper#3804 (RBAC through @relationship): tests 1-5 of SP-33 are fixed by #3316 (merged 2026-10-30). Test 6, the |
e1dc9e9 to
56a7e1f
Compare
Attribute permissions now cover JOIN ... USING columns, unqualified columns in joins (including inside ON), columns nested deeper than 100 levels, and qualifiers that do not name exactly one table in the statement. The authorization inventory resolves each column with the v2 binder's rules, now shared in sqlEngine/binder/columnScope.ts, so it checks the table the engine reads. JSON literal data and the `*` of a plain COUNT(*) are not column references. Non-super statements that read whole rows without naming a column (AlaSQL `_`, SELECT VALUE/ROW, JavaScript expressions) are refused: attribute permissions cannot apply to them, and legacy SQL's row container always carries the primary key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: harper-sql-permission-walk-using-and-container
UPDATE's WHERE and SET expressions and DELETE's WHERE now need `read` on each column they reference, separately from the write permission; the primary key is exempt, since the statement reports the keys it touched. A joined SELECT * now expands to the FROM table's readable attributes qualified by its alias, so each name binds to the table whose permissions chose it. Before, the unqualified expansion was ambiguous for any name the joined table shared, and could bind to the joined table for a name only it declared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: harper-sql-permission-walk-using-and-container
- A joined SELECT * is refused when the FROM table's alias is also claimed by another table, since the expansion is qualified by it. - An unqualified ON column no table joined so far declares falls back to the declaring tables anywhere in the statement. - UPDATE checks only SET targets for update; what SET expressions read is checked for read. UPDATE and DELETE refuse a column qualified by a name other than their table. - A JSON literal that sets __proto__ is refused for non-super users: every walk reads own properties, but AlaSQL evaluates the prototype. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: harper-sql-permission-walk-using-and-container
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: harper-sql-permission-walk-using-and-container
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: harper-sql-permission-walk-using-and-container
DELETE recorded its WHERE columns in the DELETE attribute list as well as in the read check, so a primary key with no attribute grants refused DELETE ... WHERE id = ... while the same UPDATE ran. The read check already covers every non-key predicate column, so DELETE now registers only its table there. The integration role makes the write predicates discriminating: an updatable but read-denied column, an UPDATE whose SET reads it, and a writable table whose primary key is read-denied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
The whole-row accessor check ran before SELECT * was expanded to the role's readable attributes, so a table declaring an attribute named `_` let SELECT * produce the `_` column a direct SELECT _ is refused for. The check now runs on the expanded statement, still once and still skipped for super users. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
Most statements leave several of the clauses the attribution walks (group, having, order, joins) empty; return before allocating the visited set and stack for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
Legacy SQL checks permissions on the parsed statement, then runs AlaSQL's re-parse of its toString() text. AlaSQL writes string and JSON literals without escaping, so a literal such as 'x'', ssn, ''y' came back as three columns and an UPDATE SET expression could read a column the check never saw. 'O''Brien' failed for the same reason. Harper now writes StringValue and Json literals so they decode to the same value, and the legacy path refuses with a 400 what has no spelling: a name containing a backtick, bracket or quote, or a string whose backslashes and quotes AlaSQL's lexer cannot reproduce. v2 binds the AST directly and is unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
The name check now covers every node's own string properties, so an alias on a literal is checked too, and requires @variable and $param names to be plain identifiers because AlaSQL writes them unquoted. The node walk is a loop, and refusals are ClientError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
…mbers intact Legacy SQL now re-parses the backticked text it will run and refuses a statement whose text names a column, table, function or variable the authorized statement does not, so an AlaSQL writer this code does not replace, such as the right side of `->`, fails closed. Numbers are written in plain decimal: AlaSQL wrote 1e+25 and Infinity, which read back as columns. A quote after an even run of backslashes is now written instead of refused. The alias cases check the status on every engine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
The re-parse check compared flat names, so an injected r.id passed when r and id each appeared elsewhere in the statement. It now counts each node's database, table and column together and refuses a re-parse that reads any of them more often, or that holds more than one statement. The name after -> or ! must be a bare name, since AlaSQL writes it unquoted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
Main now holds the root DESIGN.md to a byte ceiling it was already over. Shorten this branch's two sqlTranslator headings and index entries, and trim five index descriptions to their invariant; the note bodies are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
…ites The round-trip check deep-copied each statement; it now backticks the statement in place and restores it. INSERT takes literal values as data, so only its other values, which legacy writes as SELECT text, are checked: a 10k-row INSERT check drops from seconds to milliseconds, and a literal with no SQL spelling is no longer refused. In a SELECT that reads a table, SQLSearch aliases an unaliased function column as [<its text>], so a ] in that text now gets a 400 on legacy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
The restore moved any own *_orig key back, so a JSON literal holding a key named columnid_orig was rewritten. It now saves each backticked key and its _orig twin before backticking and puts back exactly those. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
SQLSearch wrote each table's primary-key result name between double quotes after the legacy SQL text check had run, so a table alias holding a double quote could end the name early. Every other generated name is already bracketed, and the check refuses brackets in names. Also test that the statement is left as it was when writing it throws. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
Writing the name only in brackets broke legacy joins on a table whose primary key contains `]`. generatedName() writes brackets, double quotes when the name holds `]`, and refuses a name that holds both. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
The `sql` operation binds no parameters, but legacy SQL runs each statement against its own rows, so `SELECT ?` returned fetched rows including a read-denied primary key, and `SET col = ?` evaluated to the existing record and echoed it in its error. getRowAccessors() now lists `?` and `$n`. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
…ling Main's root DESIGN.md is already 153 bytes over its ceiling, and the design docs check holds any change that touches it to the ceiling. The notes' bodies are unchanged; only their index descriptions are shorter. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-abc8bf322b5ccfd671a9379f3776cea4
5cf6b07 to
bab75e3
Compare
⊙ Problem
SQL attribute permissions missed some column references, so a role denied
readon a column could still filter or join on it:JOIN … USING (col), including self-joinsJOIN … ONWHERE, or a column an UPDATESETreadsWhole-row expressions (
_,SELECT VALUE, JavaScript,?/$nparameters) name no column; legacy SQL rows always carry the primary key. A restricted role'sSELECT ?returned the read-denied primary key.🤔 Should non-super users lose whole-row expressions?
Yes, as the task specified. Legacy joins merge rows by primary key; not loading an unreadable one would break them. Non-super users lose
_, a bareSELECT VALUE/ROW, JavaScript expressions (even calculation-only ones) and parameters; thesqloperation binds none. The_part could later be relaxed for roles without attribute permissions.💡 Solution
readon the columns they filter on or read.USINGreturns the same rows asON.⚖️ Alternatives
Approaches not taken
USING.USINGand unqualified join column: blocks permitted queries.🤔 Refuse a qualifier the two engines read differently, rather than resolve it?
v2 picks an alias over a table name; legacy matches either. So
… FROM data.t x JOIN data.u t …witht.idnow fails for non-super users. Resolving it v2's way would let legacy read a table other than the one checked.🤔 Exempt the primary key from the UPDATE/DELETE read check?
Yes: the statement returns the keys it touched, like NoSQL update and delete, so a write-only role keeps
UPDATE … WHERE id = …. Tablereadisn't required, so a table-read: falserole without an attribute list may filter writes on any column; one with a list may not.🤔 Refuse what legacy SQL cannot write back, rather than find a spelling for it?
Yes, on legacy only: a name holding
`,[,]or', a string ending in an odd run of backslashes, a number too large for a double, or an unaliased function column whose text holds]gets a 400. v2 accepts them. Each could be relaxed later. The check costs one extra AlaSQL parse per legacy SELECT, UPDATE or DELETE (about 1 ms for a join on a loaded box).🔧 Changes
sqlTranslator/sql_statement_bucket.ts: builds a per-statement table scope and matches each column to it; a qualifier must have exactly one claimant; an ON column falls back to later joins;USINGrecords both operands.COUNT(*)'s*. Unresolved qualifiers and whole-row expressions are reported, including__proto__literals. UPDATE/DELETE reads are listed apart from SET targets, with foreign qualifiers refused. A joinedSELECT *is qualified with the FROM alias.sqlEngine/binder/columnScope.ts: the shared rules, used bybind.tsandlogical/build.ts.utility/operation_authorization.ts: the new refusals and the UPDATE/DELETE read check, non-super users only; their messages incommonErrors.ts.sqlTranslator/legacySqlText.ts: string and number writers, installed in place of AlaSQL's byalasqlFunctionImporter.ts;assertLegacySqlTextruns before legacy SQL and re-parses the text legacy will run, counting each table-qualified column against the checked statement.dataLayer/SQLSearch.tswrites a join's primary-key result name throughgeneratedName(), so a table alias holding"cannot end it early.sqlTranslator/DESIGN.mdrecords the invariant;DESIGN.mdindexes it.✅ Verification
sql-attribute-perms.test.ts(fixture, schema): 42/42 on all three engines; every refusal case fails on origin/main, and each legacy-text case fails on the commit before its fix.5cf6b07(rebased onto main664185c):runLinterfails only on main's own tworesources/MAP.mdindex errors, which main fails too; the other 68 checks pass (a record-lock unit test on Node 24 and a Bun log-order test failed once, unrelated to this diff, and passed on rerun). RootDESIGN.mdwas pruned to its ceiling (three index descriptions shortened).e9eae90(qa-pr-harper-3804-e9eae90-r2), before the legacy-text commits.Test detail
5cf6b07: build and SQL unit suites pass (285,SQLSearch91); SQL integration suites pass (northwind 564, 13 skipped;sql-attribute-perms42 on three engines, with parameter cases that returned the denied key before); a legacy-engine server joins tables with and without aliases and with a]in the primary-key name; newlegacySqlText.test.jscovers every short string of letters, quotes and backslashes, JSON keys, numbers, names and the re-parse check.SELECT 'x'', ssn, ''y' AS v, stores1e25and1e-7exactly, and answers 400 to the->and unaliased-function cases.USING, permitted UPDATE/DELETE, super user) pass on base and head.sql-count-star-attribute-roles,sql-unqualified-table-authzandsql-not-predicatestill pass.column-attribution.test.js(attribution rules, write reads, row accessors) andcolumnScope.test.js, averifyPermsASTcase, and an updated unknown-qualifier case; SQL and authorization suites 386 passing.Authorization cost A/B (base vs head, bench-run, turbo off, median of 7)
INlist ×5000INSERT×1000 rowsINSERTwalks itsVALUESfor whole-row expressions: +433 µs per 1000 rows, 1.5% of its parse time.— Claude Opus 5.5
🤖 Generated by Anthropic Claude (Opus 5.5); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RGfHqd6v2q9t9jJ26No4Jc
Release target: main — changes SQL authorization on main; backporting to release lines is the owner's call
Related PRs: #3286 overlaps (merged; this branch is rebased onto it and carries its COUNT(*) rule into the shared column walk)
Complexity: complicated
Deferred findings
f621bcf93a29ff12minor security —sqlTranslator/sql_statement_bucket.ts— Unqualified SELECT * on a join expanded only from the FROM table — NOT FILED11fa1236c1abc837nit maintainability —sqlTranslator/sql_statement_bucket.ts— columnNodes duck-types columns by truthy columnid instead of instanceof yy.Column — NOT FILEDfadd55a1652ea2f9nit security —sqlTranslator/sql_statement_bucket.ts— Nested-subquery columns attributed with outer scope; safe only because nested queries are refused first — NOT FILED16c7f63b919b7955nit maintainability —sqlTranslator/sql_statement_bucket.ts— Derived-JOIN placeholder in attribution scope records nothing; safe only while getTableTargets refuses derived joins — NOT FILEDf7d60cec4ab79814nit test-coverage —integrationTests/security/sql-attribute-perms.test.ts— End-to-end permission matrix skipped on win32 and bun — NOT FILED8efbf8eab2ff0fdbnit performance —sqlTranslator/sql_statement_bucket.ts— getRowAccessors/getWriteStatementReads/reportForeignQualifiers re-walk the AST per non-super statement — NOT FILED5f81fd793b7edd87nit correctness —sqlTranslator/sql_statement_bucket.ts— Constant JSON literal with proto refused for non-super users (over-reject) — NOT FILEDb508750926c4c392nit security —sqlTranslator/sql_statement_bucket.ts— Literal * attribute in FIRST(*)/COUNT(DISTINCT *) recorded on FROM only and stripped by wildcard expansion — NOT FILED57cf07febc93b634nit maintainability —sqlTranslator/sql_statement_bucket.ts— Added comments narrate adjacent code or duplicate sqlTranslator/DESIGN.md — NOT FILEDLate Grok findings
9 surviving (highest nit) — Grok reviewed 532b94a, ruled against 0bc12d0
sqlTranslator/sql_statement_bucket.ts:312— A constant JSON literal with__proto__is still refused for non-super users. The refusal now comes first from the unresolved-column check (utility/operation_authorization.ts:623-626), so the user gets an unresolved-column error instead of the row-accessor error. Scope: in-scope — Convergence-ClasqlTranslator/sql_statement_bucket.ts:318—columnNodestreats any node with a truthycolumnidas a column. It does not checkinstanceof yy.Column. Scope: in-scope — Convergence-Class: previously-adjudicatedsqlTranslator/sql_statement_bucket.ts:641— Column nodes inside nested subqueries are attributed to the outer scope. This is safe only becausegetTableTargetsrefuses nested queries first (:382-385). Scope: in-scope — Convergence-Class: previously-adjudicatedintegrationTests/security/sql-attribute-perms.test.ts:18— The end-to-end permission matrix is skipped on win32 and Bun. Scope: in-scope — Convergence-Class: previously-adjudicatedsqlTranslator/sql_statement_bucket.ts:145— Each non-super statement walks the AST up to three more times (row accessors, write reads, columns). Scope: in-scope — Convergence-Class: previously-adjudicatedsqlTranslator/sql_statement_bucket.ts:536— A derived JOIN keeps an attribution slot that records nothing. This is safe only whilegetTableTargetsrefuses derived joins (:397-401). Scope: in-scope — Convergence-Class: previously-adjudicatedsqlTranslator/sql_statement_bucket.ts:590— Some added comments narrate history ("rather than dying here … as a 500") or repeatsqlTranslator/DESIGN.md. Same applies at:230-231and:150-151. Scope: in-scope — Convergence-Class: previously-adjudicatedsqlTranslator/sql_statement_bucket.ts:632— Partially resolved. The fallback to tables joined later only fires when the ON prefix has zero owners. If a prefix table hasattributesKnown: false, every prefix table counts as an owner, the fallback never runs, and a later table that declares the column is not recorded. Scope: in-scope — ConvergunitTests/sqlTranslator/column-attribution.test.js:197— No test coversgetWriteStatementReadswith a JSON literal in UPDATE SET. The existingSET label = {columnid:'ssn'}assertion cannot fail any more, becausegetUpdateAttributesno longer walks SET expressions. Scope: in-scope — Convergence-Class: previously-adjudicatedNot standing: 4 resolved. Grok ran 16m (16m from launch to report); the fold adjudication (
cursor-grok@532b94aae8c4-d8290b18, claude) took 2m.Origin — the dispatch brief this PR was written from
Make the SQL attribute-permission check cover every way a statement can reference a column. (1)
sqlTranslator/sql_statement_bucket.ts(~603-631) walksjoin.onbut neverjoin.using, so columns named inJOIN ... USING (col)are not checked against either side's attribute_permissions (anON a.col = b.colwith the same column is refused). Walkjoin.using, qualifying each column against every joined table that declares it, on both sql engines. (2) Legacy SQL always loads the table's primary key into the row container given to AlaSQL (dataLayer/SQLSearch.ts~637-642), so column-free accessors such asVALUE/->reach it even when the role cannot read the primary key. Do not load an attribute the role cannot read, or refuse non-super statements that use column-free row accessors.Acceptance
Integration tests on legacy, auto and new engines: a role with read:false on a column gets 403 for JOIN ... USING on that column (two tables and self-join) and for a column-free accessor reaching a read-denied primary key; USING on a permitted column returns the same rows as the equivalent ON; super user unchanged. Shown failing on origin/main.
Dispatch: task
harper-sql-permission-walk-using-and-container· queued by finding-triage/wave-36 · ran by claude/opus/high · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=27; full=9 @ bab75e3
Review-Attention: study ~9m (decisions: do-less-alternative, row-accessor-refusal-breadth, write-reads-pk-exempt, legacy-refuses-unwritable-names) @ bab75e3