Skip to content

Apply SQL attribute permissions to every column a statement references - #3804

Merged
kriszyp merged 19 commits into
mainfrom
fix/sql-attribute-perms-using-and-row-accessors
Nov 3, 2026
Merged

kriszyp merged 19 commits into
mainfrom
fix/sql-attribute-perms-using-and-row-accessors

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 30, 2026 •

Copy link
Copy Markdown
Member
Kids checking every pack pocket against permits

⊙ Problem

SQL attribute permissions missed some column references, so a role denied read on a column could still filter or join on it:

  • JOIN … USING (col), including self-joins
  • an unqualified column inside JOIN … ON
  • a qualifier naming no table, or one table's alias and another's name
  • a column nested 100+ levels deep
  • an UPDATE or DELETE WHERE, or a column an UPDATE SET reads
  • on legacy SQL, a string, name or number that reads back as more columns when legacy writes the checked statement out as SQL text and parses it again

Whole-row expressions (_, SELECT VALUE, JavaScript, ?/$n parameters) name no column; legacy SQL rows always carry the primary key. A restricted role's SELECT ? 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 bare SELECT VALUE/ROW, JavaScript expressions (even calculation-only ones) and parameters; the sql operation binds none. The _ part could later be relaxed for roles without attribute permissions.

💡 Solution

  • Each column is checked on the table the engine reads (rules shared with the v2 binder); a denied one gets a 403.
  • Non-super users get a 403 for a qualifier naming no single table, and for whole-row expressions.
  • UPDATE and DELETE also need read on the columns they filter on or read.
  • Legacy SQL writes string, JSON and number literals so they read back unchanged. It refuses with a 400 a statement whose text would read anything the checked statement does not; an INSERT is checked only on the values it writes as text.
  • Super users are unchanged; on v2, permitted USING returns the same rows as ON.

⚖️ Alternatives

Approaches not taken
  • Stop loading unreadable primary keys in legacy SQL: legacy joins merge rows by primary key, and this does nothing for the v2 engine or USING.
  • Authorize the v2 engine's bound plan: legacy never binds; two authorizers could disagree.
  • Refuse every USING and 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 … with t.id now 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 = …. Table read isn't required, so a table-read: false role 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

✅ Verification

  • New 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.
  • CI on 5cf6b07 (rebased onto main 664185c): runLinter fails only on main's own two resources/MAP.md index 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). Root DESIGN.md was pruned to its ceiling (three index descriptions shortened).
  • PR QA: passed on e9eae90 (qa-pr-harper-3804-e9eae90-r2), before the legacy-text commits.
Test detail
  • Local on 5cf6b07: build and SQL unit suites pass (285, SQLSearch 91); SQL integration suites pass (northwind 564, 13 skipped; sql-attribute-perms 42 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; new legacySqlText.test.js covers every short string of letters, quotes and backslashes, JSON keys, numbers, names and the re-parse check.
  • A live legacy-engine server returns the literal for SELECT 'x'', ssn, ''y' AS v, stores 1e25 and 1e-7 exactly, and answers 400 to the -> and unaliased-function cases.
  • 12 cases × 3 engines; the controls (permitted USING, permitted UPDATE/DELETE, super user) pass on base and head. sql-count-star-attribute-roles, sql-unqualified-table-authz and sql-not-predicate still pass.
  • Unit: new column-attribution.test.js (attribution rules, write reads, row accessors) and columnScope.test.js, a verifyPermsAST case, 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)
Statement base head alasql.parse
single-table SELECT 7.6 µs 7.3 µs 107 µs
two-table join 6.3 µs 5.7 µs 228 µs
IN list ×5000 6176 µs 1078 µs 40 ms
INSERT ×1000 rows 3.3 µs 436 µs 29 ms

INSERT walks its VALUES for 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

  • f621bcf93a29ff12 minor security — sqlTranslator/sql_statement_bucket.ts — Unqualified SELECT * on a join expanded only from the FROM table — NOT FILED
  • 11fa1236c1abc837 nit maintainability — sqlTranslator/sql_statement_bucket.ts — columnNodes duck-types columns by truthy columnid instead of instanceof yy.Column — NOT FILED
  • fadd55a1652ea2f9 nit security — sqlTranslator/sql_statement_bucket.ts — Nested-subquery columns attributed with outer scope; safe only because nested queries are refused first — NOT FILED
  • 16c7f63b919b7955 nit maintainability — sqlTranslator/sql_statement_bucket.ts — Derived-JOIN placeholder in attribution scope records nothing; safe only while getTableTargets refuses derived joins — NOT FILED
  • f7d60cec4ab79814 nit test-coverage — integrationTests/security/sql-attribute-perms.test.ts — End-to-end permission matrix skipped on win32 and bun — NOT FILED
  • 8efbf8eab2ff0fdb nit performance — sqlTranslator/sql_statement_bucket.ts — getRowAccessors/getWriteStatementReads/reportForeignQualifiers re-walk the AST per non-super statement — NOT FILED
  • 5f81fd793b7edd87 nit correctness — sqlTranslator/sql_statement_bucket.ts — Constant JSON literal with proto refused for non-super users (over-reject) — NOT FILED
  • b508750926c4c392 nit security — sqlTranslator/sql_statement_bucket.ts — Literal * attribute in FIRST(*)/COUNT(DISTINCT *) recorded on FROM only and stripped by wildcard expansion — NOT FILED
  • 57cf07febc93b634 nit maintainability — sqlTranslator/sql_statement_bucket.ts — Added comments narrate adjacent code or duplicate sqlTranslator/DESIGN.md — NOT FILED

Late Grok findings

9 surviving (highest nit) — Grok reviewed 532b94a, ruled against 0bc12d0
  • nit — 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-Cla
  • nit — sqlTranslator/sql_statement_bucket.ts:318 — columnNodes treats any node with a truthy columnid as a column. It does not check instanceof yy.Column. Scope: in-scope — Convergence-Class: previously-adjudicated
  • nit — sqlTranslator/sql_statement_bucket.ts:641 — Column nodes inside nested subqueries are attributed to the outer scope. This is safe only because getTableTargets refuses nested queries first (:382-385). Scope: in-scope — Convergence-Class: previously-adjudicated
  • nit — integrationTests/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-adjudicated
  • nit — sqlTranslator/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-adjudicated
  • nit — sqlTranslator/sql_statement_bucket.ts:536 — A derived JOIN keeps an attribution slot that records nothing. This is safe only while getTableTargets refuses derived joins (:397-401). Scope: in-scope — Convergence-Class: previously-adjudicated
  • nit — sqlTranslator/sql_statement_bucket.ts:590 — Some added comments narrate history ("rather than dying here … as a 500") or repeat sqlTranslator/DESIGN.md. Same applies at :230-231 and :150-151. Scope: in-scope — Convergence-Class: previously-adjudicated
  • nit — sqlTranslator/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 has attributesKnown: 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 — Converg
  • nit — unitTests/sqlTranslator/column-attribution.test.js:197 — No test covers getWriteStatementReads with a JSON literal in UPDATE SET. The existing SET label = {columnid:'ssn'} assertion cannot fail any more, because getUpdateAttributes no longer walks SET expressions. Scope: in-scope — Convergence-Class: previously-adjudicated

Not 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) walks join.on but never join.using, so columns named in JOIN ... USING (col) are not checked against either side's attribute_permissions (an ON a.col = b.col with the same column is refused). Walk join.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 as VALUE / -> 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-1

Review-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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kriszyp
kriszyp force-pushed the fix/sql-attribute-perms-using-and-row-accessors branch 2 times, most recently from e99f454 to e1dc9e9 Compare November 2, 2026 07:32
@kriszyp
kriszyp marked this pull request as ready for review November 2, 2026 15:04
@kriszyp
kriszyp requested a review from cb1kenobi November 2, 2026 15:04
@kriszyp

kriszyp commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Comment for harper#3804 (RBAC through @relationship): tests 1-5 of SP-33 are fixed by #3316 (merged 2026-10-30). Test 6, the JOIN ... USING leak, remains: sqlTranslator/sql_statement_bucket.ts only walks join.on (~611), so USING columns skip the attribute gate. #3804 is open and no longer a draft but reports CONFLICTING; needs a rebase. P1, effort Low.

@kriszyp
kriszyp force-pushed the fix/sql-attribute-perms-using-and-row-accessors branch from e1dc9e9 to 56a7e1f Compare November 2, 2026 17:37
kriszyp and others added 19 commits November 2, 2026 11:23
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
@kriszyp
kriszyp force-pushed the fix/sql-attribute-perms-using-and-row-accessors branch from 5cf6b07 to bab75e3 Compare November 2, 2026 18:38
@kriszyp
kriszyp merged commit 287acd2 into main Nov 3, 2026
74 checks passed
@kriszyp
kriszyp deleted the fix/sql-attribute-perms-using-and-row-accessors branch November 3, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant