Uh oh!
There was an error while loading. Please reload this page.
adapter: authorize source access before purification - #38501
Conversation
Fixes SQL-655 (2026 Refactor penetration test, finding H02, High). ### Motivation `CREATE TABLE ... FROM SOURCE` bypassed source authorization. The statement plans to a generic `Plan::CreateTable`, whose RBAC arm required only `CREATE` on the destination schema and never inspected `table.data_source`. Because the resulting table is owned by whoever creates it, `CREATE` on any schema a role controls was enough to read a source that role had been explicitly denied. The assessment read 4,076 rows from an Auction load generator through an attached table while direct reads of the same export stayed denied. The behaviour has been reachable by default since v26.25.0 and was still present in v26.35.0. ### Description The `Plan::CreateTable` arm now inspects `table.data_source` and, for an `IngestionExport`, applies a column-coverage rule: a role may attach a reference only if it can already read every column the new table would expose. It can if * it owns the source, or * it can read an existing export of the reference (an export or subsource whose `RelationDesc` projects at least the planned columns), requiring `SELECT` on the source and on that covering export. When neither holds, the attach opens a new or wider read path to upstream data, so it requires ownership of the source, matching `ALTER SOURCE ... ADD SUBSOURCE`. Column names are only a read proxy where they come from the upstream schema: postgres, mysql, sql_server, and multi-output load generators reject caller-supplied names, so equal names mean the same data. Kafka is excluded from the coverage path and always requires ownership, because the caller picks `FORMAT`/`ENVELOPE`/`INCLUDE` and renames the resulting columns, so equal names there can hide raw payload bytes, headers, and partition metadata a covering export never projected. Keying on column coverage rather than on the set of exports is what makes this neither over-strict nor under-strict. A sibling table exporting the same reference does not lock other roles out, because the requirement is a covering export the role can read, not read on every export. And an export created with `EXCLUDE COLUMNS` does not authorize a wider table for the same reference, because it does not cover the excluded column, so that case falls through to the ownership requirement. Because "read via any covering export" is a disjunction the conjunctive `RbacRequirements` cannot express, the arm evaluates the role's holdings at requirement-generation time (a small `role_holds_privileges` helper) to pick a covering export the role can read, and otherwise names one anyway so the denial points at a `SELECT` grant that would authorize the attach rather than at ownership. Superusers and RBAC-off deployments are unaffected: `filter_to_mandatory_requirements` strips `AclMode::SELECT`. ### User-visible effect This tightens an existing privilege requirement, and `enable_create_table_from_source` is on by default, so it applies to everyone on upgrade. A role that previously needed only `CREATE` on its own schema plus `USAGE` on the source's schema now also needs `SELECT` on the source and on any already-cataloged export of the reference being attached. Deployments where a platform team owns sources and application teams attach tables into their own schemas will need those `SELECT` grants added. The user-facing privileges include for `CREATE TABLE` is updated in a follow-up PR, kept separate so this fix is not gated on a second CODEOWNERS scope. When nothing has yet exported the reference, there is no export and therefore no existing item whose privileges say who may read it. Attaching it is creating a new read path to upstream data, so that case requires **ownership** of the source rather than `SELECT`. This matches `ALTER SOURCE ... ADD SUBSOURCE`, the old-syntax operation it replaces. `SELECT` would be the wrong gate there: for a multi-output source the parent carries progress and offset metadata rather than user rows, so granting it reads as "let them see ingestion progress" and should not also mean "let them replicate any table in the publication". ### Verification New `test/sqllogictest/rbac_create_table_from_source.slt` walks the privilege set one grant at a time: a direct read is denied; the attach is denied; the attach is still denied after `SELECT` on the parent alone, which is what pins the export requirement; and it succeeds once the full set is granted, so the check does not block legitimate attachment. Deleting either half of the requirement fails a different assertion, so neither passes vacuously. Registered in `tests_without_views` alongside the other RBAC suites, since `--auto-index-selects` view-wrapping would change privilege semantics. ### Residual, not fixed here Purification runs before authorization. `must_spawn_purification` gates it on `CREATE_ITEM_USAGE`, which covers secrets, connections and types but not sources, and the upstream connection is taken from the source's own `source_desc()` rather than from the statement's resolved ids. An unauthorized role can therefore still cause an outbound connection to the source's upstream using the source owner's credentials through a connection it holds no `USAGE` on, and use purification errors as an upstream existence and column-name oracle. This is shared with `CREATE SOURCE` and `CREATE SINK` rather than introduced here, and closing it needs a pre-purification privilege gate. Filed separately. The column-restricted (`EXCLUDE COLUMNS`) escalation the coverage rule closes is only reachable on the postgres, mysql, and sql_server connectors, which sqllogictest cannot drive, so it is covered by the logic but not by an added assertion. It should get a testdrive case against a network connector. The load-generator test does cover the parent-plus-export requirement, the no-existing-export ownership fallback, and that a sibling table does not lock another role out.
Purification runs before planning. When a statement names an existing source, purification borrows that source's upstream connection and secrets to contact the upstream. The pre-purification check only covers the secrets, connections, and types in the statement's resolved ids, so the borrowed connection is never authorized against the caller, and a role the plan would reject can still trigger an outbound connection with the owner's credentials and read purification errors as an upstream oracle. Gate purification on the sources a statement names: ownership for ALTER SOURCE, and SELECT or ownership for every other purified statement. Both are necessary conditions of the plan-time requirement, so no statement the plan would accept is rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
de26cb6 to
7139192Comparejasonhernandez
commented
Aug 26, 2026
@tonydu-mz I think #38480 is a sufficient immediate fix, but you may want to merge this into it. We could also address this later if there isn't time in this release cycle. |
5c440d0 to
1699d8aComparedef-
commented
Aug 27, 2026
QA LLM Review1. HIGH -- The |
Stacked on #38480. Review only the top commit until that merges; the base will be retargeted to
mainafterwards.Motivation
#38480 closes the plan-time bug in SQL-655 but leaves a residual it calls out: purification runs before planning, and
must_spawn_purificationonly authorizes the secrets, connections, and types in the statement's resolved ids.CREATE TABLE ... FROM SOURCEandALTER SOURCEtake the upstream connection from the named source's ownsource_desc(), which is not in the resolved ids, so a role the plan would reject can still make Materialize open an outbound connection to the upstream using the source owner's credentials, and read purification errors as an upstream existence and column-name oracle.CREATE SOURCE ... FROM CONNECTIONandCREATE SINK ... INTOare not affected: the connection they use is in their resolved ids and the existingcheck_usage(CREATE_ITEM_USAGE)call already gates it before purification. No new privilege is needed.Description
Add
rbac::check_purification_source_accessand call it next to the existingcheck_usagein the purification task. For everySourceitem the statement names it requires:ALTER SOURCE: ownership of the source.SELECTon the source (with schemaUSAGE), or ownership.Each is a necessary condition of the corresponding plan-time requirement (
ALTER SOURCErequires ownership;CREATE TABLE ... FROM SOURCEafter #38480 requiresSELECTon the parent or ownership;CREATE SINK FROMrequires read on its input), so no statement the plan would accept is rejected here. Superuser and RBAC-off sessions are unaffected through the usualfilter_requirements.This is a necessary-condition gate, not a duplicate of the plan-time check. The column-coverage rule in #38480 needs the purified schema and stays where it is.
User-visible effect
A role without
SELECTon a source now gets aSELECTdenial fromCREATE TABLE ... FROM SOURCEinstead of an ownership denial, and gets it before any upstream I/O. No previously permitted statement becomes denied.Verification
Extends
test/sqllogictest/rbac_create_table_from_source.sltwith the one case the gate is observable on: a non-owner with noSELECTon a never-exported source is denied forSELECTrather than for ownership. Confirmed the assertion fails without thecommand_handler.rscall and passes with it.Part of SQL-655.
🤖 Generated with Claude Code