Skip to content

Require read privileges for CREATE TABLE ... FROM SOURCE - #38480

Open
tonydu-mz wants to merge 1 commit into
mainfrom
tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02
Open

Require read privileges for CREATE TABLE ... FROM SOURCE#38480
tonydu-mz wants to merge 1 commit into
mainfrom
tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02

Conversation

@tonydu-mz

@tonydu-mztonydu-mz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Require read privileges for CREATE TABLE ... FROM SOURCE

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, requires the privileges reading that source requires:
SELECT on the source plus USAGE on its schema, via generate_read_privileges.
This is the same shape as CREATE SINK, which requires read on the relation it
reads.

The source is the authorization boundary.SELECT on a source permits
attaching any reference that source ingests, including references that have no
cataloged export yet, and including columns that some existing export omitted.
If a reference should not be reachable, it should not be in the source's
publication or topic set.

An earlier revision of this change tried to be finer-grained, authorizing an
attach when the role could already read an existing export of the same reference
whose columns covered the new table's. That is not implemented, deliberately.
It made the outcome depend on unrelated catalog state: narrowing or dropping one
team's table could revoke another team's ability to run an unchanged statement,
with an error naming the source rather than the table that actually changed. No
other part of the RBAC model works that way, and per-column granularity belongs
in real column-level grants rather than being inferred from which exports happen
to exist.

This means pentest remediation item 2 ("require SELECT on the selected
cataloged subsource or source export when one exists") is intentionally not
implemented, since it is that same catalog-dependent rule. Item 1 is what closes
the finding, and it is implemented here.

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.

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.

Verification

New test/sqllogictest/rbac_create_table_from_source.slt pins both halves of the
requirement and the stability property: a direct read is denied; the attach is
denied without SELECT on the source; the attach is denied for a role that has
SELECT on the source but no CREATE on the destination schema; it succeeds
with both; a second role with the same grants can attach the same reference after
another team has already exported it; and a reference with no existing export is
authorized identically, so the rule does not vary with catalog shape.

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.

@linear-code

Copy link
Copy Markdown

SQL-655

@tonydu-mz
tonydu-mzforce-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from 598170c to 6d29cb0CompareAugust 26, 2026 14:49
@tonydu-mz
tonydu-mzforce-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 6d29cb0 to 1d929f7CompareAugust 26, 2026 14:53
@tonydu-mz
tonydu-mz marked this pull request as ready for review August 26, 2026 15:29
@tonydu-mz
tonydu-mz requested a review from a team as a code ownerAugust 26, 2026 15:29
@def-

def- commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Requiring read on every export of the reference lets one tenant's table block all other roles, including the source owner

src/sql/src/rbac.rs:701

Because the requirement is the conjunction over all cataloged exports of the reference, a single export sitting in a schema other roles cannot see denies the attach to everyone else, the source owner included, even when they can already read that reference through an export they are authorized for. In the deployment shape the PR description targets (platform team owns sources, app teams attach tables into their own schemas), the first app team to attach a reference locks out every other team and the platform team.

Details

export_ids collects all matching exports at rbac.rs:676-688 and generate_read_privileges then demands SELECT on each one plus USAGE on each containing schema.

Concretely: the platform team owns source s and export t_r for reference r, and grants SELECT on both to teams X and Y. X runs CREATE TABLE x.t FROM SOURCE s (REFERENCE r) and is allowed. Y's identical statement now additionally requires SELECT on x.t and USAGE on schema x, so Y is denied. So is the platform team, which owns s but holds nothing on x.t. There is no unilateral recovery: the source owner cannot drop or grant on X's table.

That is stricter than the stated goal of requiring "the privileges a direct read of that data requires" — reading r's rows requires SELECT on one export, not on every copy of it. A side effect is that the denial names materialize.x.t to a role with no USAGE on schema x, disclosing object names across schemas.

The sound criterion is disjunctive rather than conjunctive: ownership of the parent, or SELECT on the parent plus SELECT on some export of the reference whose RelationDesc covers every column the new table would have. The column-coverage clause is what keeps "some export" from degenerating into "the weakest export", which is the case finding 2 covers.

2. MEDIUM -- SELECT on a column-restricted export is enough to create an unrestricted export of the same reference

src/sql/src/rbac.rs:676

A role holding SELECT on the parent source and on a single export created with EXCLUDE COLUMNS can attach its own table for that reference without the exclusion and read columns that no Materialize object it was granted exposes. Requiring read on all exports does not close this, so the EXCLUDE COLUMNS case the comment at rbac.rs:670-675 cites as the motivation for the all-exports rule is still open.

Details

EXCLUDE COLUMNS is per-export and shrinks the export's RelationDesc during purification (to_desc(text_cols, exclude_columns), src/sql/src/pure/mysql.rs:410; the postgres and sql_server paths are equivalent). Purification of a new CREATE TABLE ... FROM SOURCE re-reads the upstream schema rather than consulting any existing export, so omitting the option yields the full column list.

So: an admin creates t_r from reference r with EXCLUDE COLUMNS (ssn) and grants SELECT on s and t_r. A role with CREATE on its own schema runs CREATE TABLE mine FROM SOURCE s (REFERENCE r) with no exclusion. The new arm requires SELECT on s and on t_r only, both held, and the role now reads ssn.

The empty-export_ids branch already recognises this shape — attaching a reference nothing has exported "creates a new read path to upstream data", so it demands ownership. A widening projection on an already-exported reference is the same new read path, but it takes the SELECT path instead. Comparing the planned table's column set against the exports the role can actually read (and falling back to the ownership requirement when it is not covered) fixes this and finding 1 together.

@tonydu-mz
tonydu-mzforce-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from ff68319 to 4254e51CompareAugust 26, 2026 16:25
@def-

def- commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Column-name coverage is not a read-authorization proxy on Kafka sources, where the caller picks the column names

src/sql/src/rbac.rs:705

The coverage rule compares column name sets, but CREATE TABLE ... FROM SOURCE lets the caller name the new table's columns for Kafka and single-output load-generator sources. A role holding SELECT on a Kafka source and on any one export of a topic can therefore attach a table with a different FORMAT/ENVELOPE/INCLUDE list, rename its columns to a subset of the readable export's names, and satisfy coverage while reading message keys, headers, partition/offset metadata, and raw payload bytes that no object it was granted exposes.

Details

plan_create_table_from_source applies maybe_rename_columns to the format-derived desc at src/sql/src/plan/statement/ddl.rs:1969, and maybe_rename_columns (src/sql/src/plan/plan_utils.rs:27) renames the first N columns positionally with no relation to what the names meant before. Purification rejects TableFromSourceColumns::Named for postgres, mysql, sql_server, and multi-output load generators (src/sql/src/pure.rs:2095, :2145, :2188, :2228), which is why the rule holds on those connectors, but Kafka takes the else branch in plan_create_table_from_source and never reaches those checks. So the only bound on the attach is that the planned table has no more columns than the widest readable export of the same reference, and that its names are drawn from that export's name set.

Concretely, on the shape in test/testdrive/source-tables.td:405-417 (three tables on one Kafka topic, one of them with caller-supplied names): the platform team owns avro_source, owns avro_table_upsert with columns {key, f1, f2}, and grants SELECT on the source and on that table to role app. app runs

CREATETABLEapp_schema.raw (key, f1, f2) FROM SOURCE avro_source (REFERENCE 'topic')
FORMAT BYTES ENVELOPE NONE INCLUDE HEADERS, PARTITION;

The desc is [data bytea, headers, partition], renamed to {key, f1, f2}, which is a subset of {key, f1, f2}. covering_exports accepts avro_table_upsert, role_holds_privileges finds it readable, and the requirement collapses to SELECT on the source plus SELECT on avro_table_upsert, both held. app_schema.raw now exposes the raw serialized value and the Kafka headers, neither of which the granted table projected. KEY FORMAT BYTES ... INCLUDE KEY gets the raw message keys the same way.

Column names carry meaning on the connectors where they come from the upstream schema, and none on the connectors where the caller supplies them. Restricting the coverage branch to the reference types whose names are purification-derived, and falling through to the ownership requirement otherwise, would keep the rule sound; comparing the planned table's data_config (envelope, encoding, metadata columns) against the covering export's would be the more precise version, since for Kafka it is the format and INCLUDE list, not the column names, that decide what upstream bytes the table exposes.

@tonydu-mz
tonydu-mzforce-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from fb5c503 to 5c440d0CompareAugust 26, 2026 17:56
@tonydu-mz

Copy link
Copy Markdown
ContributorAuthor

Thanks Dennis! I address all the comments

@bosconibosconi added the release-blocker Critical issue that should block *any* release if not fixed label Aug 26, 2026

@SangJunBakSangJunBak 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.

On reading this change, at a high level, it feel like it's a bit hacky/not principled. Although the motivation is clear, I don't like the implicitness of allowing access based on existing privileges of adjacent/similar looking tables.

Take the following example:
Team B has SELECT on source pg_src and on source table safe.customers, and their dbt run attaches source table public.customers fine on Monday. On Tuesday the platform team drops or narrows safe.customers during their own migration. Team B's identical dbt run now fails with must be owner of SOURCE pg_src.

Nothing else in our RBAC model follows this pattern. For the time being, I think I'd rather make the RBAC policy a bit dumb and either:

  • Assert that the table creator must be an owner of the source (means separate teams can't export source tables separate from the platform team. Might be a breaking change)
  • Assert that the table creator must also have SELECT privilege on the source, similar to sinks (means sub team A can access everything from sub team B). This would be my pick (also don't forget to update docs!!)

In the longer term, I think something really nice could be PostgreSQL column level SELECT grants where you can grant SELECT on certain columns of a relation. Syntax could look like:

GRANT SELECT (name, department)
ON pg_src (REFERENCE "public"."customers")
TO analyst;

but this would be a larger feature.

@tonydu-mz

Copy link
Copy Markdown
ContributorAuthor

Thanks Jun. That's a fair point. I'll take a look at this more tonight

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`, requires the privileges reading that source requires:
`SELECT` on the source plus `USAGE` on its schema, via `generate_read_privileges`.
This is the same shape as `CREATE SINK`, which requires read on the relation it
reads.
**The source is the authorization boundary.** `SELECT` on a source permits
attaching any reference that source ingests, including references that have no
cataloged export yet, and including columns that some existing export omitted.
If a reference should not be reachable, it should not be in the source's
publication or topic set.
An earlier revision of this change tried to be finer-grained, authorizing an
attach when the role could already read an existing export of the same reference
whose columns covered the new table's. That is not implemented, deliberately.
It made the outcome depend on unrelated catalog state: narrowing or dropping one
team's table could revoke another team's ability to run an unchanged statement,
with an error naming the source rather than the table that actually changed. No
other part of the RBAC model works that way, and per-column granularity belongs
in real column-level grants rather than being inferred from which exports happen
to exist.
This means pentest remediation item 2 ("require `SELECT` on the selected
cataloged subsource or source export when one exists") is intentionally not
implemented, since it is that same catalog-dependent rule. Item 1 is what closes
the finding, and it is implemented here.
### 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.
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.
### Verification
New `test/sqllogictest/rbac_create_table_from_source.slt` pins both halves of the
requirement and the stability property: a direct read is denied; the attach is
denied without `SELECT` on the source; the attach is denied for a role that has
`SELECT` on the source but no `CREATE` on the destination schema; it succeeds
with both; a second role with the same grants can attach the same reference after
another team has already exported it; and a reference with no existing export is
authorized identically, so the rule does not vary with catalog shape.
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.
@tonydu-mz
tonydu-mzforce-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 5c440d0 to 1699d8aCompareAugust 27, 2026 01:37
tonydu-mz added a commit that referenced this pull request Aug 27, 2026
### Motivation
`CREATE TABLE ... FROM SOURCE` populates the new table from an existing
ingestion, so creating it reads that source's data.
#38480 makes the privilege requirements match that,
fixing a case where `CREATE` on the destination schema alone was enough to read
a source the role had been denied (SQL-655, 2026 penetration test finding H02).
The privileges include for `CREATE TABLE` still lists only the schema and type
requirements, so it understates what the statement needs.
### Description
Add the read requirement to `create-table.md`, the single include used by all
five `CREATE TABLE` pages and by the generated privileges appendix: `SELECT` on
the source plus `USAGE` on its schema.
The note about scope is the part worth reading. The source is the authorization
boundary, so `SELECT` on it permits attaching any reference that source ingests,
including references with no existing table and columns some existing table
omitted. An admin deciding whether to grant it needs that sentence.
Split out from #38480 so that fix, which is an urgent security finding, is not
gated on a second CODEOWNERS scope. It is accurate to merge this either before
or after #38480: before, it documents a requirement that is about to exist;
after, it closes a gap where the docs understate what is enforced.
### Verification
Prose only. Rendering is unchanged in shape, four bullets where there were three.
@tonydu-mz

Copy link
Copy Markdown
ContributorAuthor

Thanks @SangJunBak !
Took your second option. The coverage rule is gone; CREATE TABLE ... FROM SOURCE now just requires SELECT on the source plus USAGE on its schema, the same shape CREATE SINK uses for the relation it reads.

Your instability point make a lot of sense. Authorization that depends on which other exports happen to exist means one team narrowing its own table silently breaks another team's unchanged dbt run, with an error naming the source rather than the table that actually changed. That's not a tradeoff worth the finer granularity.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blockerCritical issue that should block *any* release if not fixed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@tonydu-mz@def-@SangJunBak@bosconi