Skip to content

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

Merged
tonydu-mz merged 1 commit into
mainfrom
tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02
Aug 27, 2026
Merged

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

Conversation

@tonydu-mz

@tonydu-mz tonydu-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: it required only
CREATE on the destination schema, and nothing about the source it reads.
Because the new 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

CREATE TABLE ... FROM SOURCE now requires SELECT on the source and USAGE
on its schema, the same read privileges CREATE SINK requires 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
table yet, and including columns some existing table omitted. If a reference
should not be reachable, it should not be in the source's publication or topic
set.

Pentest remediation item 2 (additionally requiring SELECT on an existing table
for the same reference) is intentionally not implemented: making the outcome
depend on which other tables happen to exist would let one team's migration
revoke another team's ability to run an unchanged statement. Per-column
granularity belongs in column-level grants.

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: 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; and a second role with the same
grants can attach the same reference after another team has already attached it.

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, and its gate covers secrets, connections
and types but not sources. The upstream connection also comes from the source's
own definition rather than the statement, so an unauthorized role can still
cause an outbound connection to the source's upstream using the source owner's
credentials, and use purification errors as an upstream existence and
column-name oracle. Shared with CREATE SOURCE and CREATE SINK rather than
introduced here; closing it needs a pre-purification privilege gate. Filed
separately.

@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

SQL-655

@tonydu-mz
tonydu-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from 598170c to 6d29cb0 Compare August 26, 2026 14:49
@tonydu-mz
tonydu-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 6d29cb0 to 1d929f7 Compare August 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 owner August 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-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from ff68319 to 4254e51 Compare August 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

CREATE TABLE app_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-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch 2 times, most recently from fb5c503 to 5c440d0 Compare August 26, 2026 17:56
@tonydu-mz

Copy link
Copy Markdown
Contributor Author

Thanks Dennis! I address all the comments

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

@SangJunBak SangJunBak 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
Contributor Author

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

@tonydu-mz
tonydu-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 5c440d0 to 1699d8a Compare August 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
tonydu-mz requested a review from SangJunBak August 27, 2026 02:25
@tonydu-mz

Copy link
Copy Markdown
Contributor Author

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.

# `CREATE TABLE ... FROM SOURCE` must require the same read privileges as
# reading the source directly.
#
# The statement plans to a generic `Plan::CreateTable`, so its RBAC arm has to

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.

no need to talk about implementation details here, these will rot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated - thanks Ajoscha

Comment thread src/sql/src/rbac.rs Outdated
Comment on lines +647 to +656
// `CREATE TABLE ... FROM SOURCE` populates the new table from an
// existing ingestion, so creating it reads that source's data.
// Require what reading the source requires, the same way
// `CREATE SINK` requires read on the relation it reads.
//
// The source is the authorization boundary: `SELECT` on it permits
// attaching any reference it ingests. Per-column or per-reference
// granularity would need real column-level grants rather than being
// inferred from which exports happen to exist, which would make the
// outcome depend on unrelated catalog state.

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.

Just saying sth like create table ... from sources requires select priviledges on the source should be enough here. The rest is the history of how we arrived at the current setup and noise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated. Thanks Aljsocha

maheshwarip pushed a commit that referenced this pull request Aug 27, 2026
…38491)

Document the read privileges CREATE TABLE ... FROM SOURCE requires

### 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](https://linear.app/materializeinc/issue/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.

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

➕ to Aljoscha's suggestion about the comment, but overall looks good to me! Good work and thanks for making this change Tony!

Comment on lines +178 to +199
# A reference nothing has exported yet is authorized the same way, so the rule
# does not change with the shape of the catalog.

simple conn=mz_system,user=mz_system
CREATE SOURCE victim_schema.unexported FROM LOAD GENERATOR AUCTION (AS OF 300, UP TO 301);
----
COMPLETE 0

simple conn=attacker,user=attacker
CREATE TABLE attacker_schema.first_attach FROM SOURCE victim_schema.unexported (REFERENCE "auction"."users");
----
db error: ERROR: permission denied for SOURCE "materialize.victim_schema.unexported"
DETAIL: The 'attacker' role needs SELECT privileges on SOURCE "materialize.victim_schema.unexported"

simple conn=mz_system,user=mz_system
GRANT SELECT ON victim_schema.unexported TO attacker;
----
COMPLETE 0

simple conn=attacker,user=attacker
CREATE TABLE attacker_schema.first_attach FROM SOURCE victim_schema.unexported (REFERENCE "auction"."users");
----

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.

nit: This test feels a bit redundant given we're testing that RBAC privileges of one object shouldn't affect another in the same schema. I'd remove it!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you! I updated

@tonydu-mz
tonydu-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 1699d8a to 1bde35e Compare August 27, 2026 15:48
Fixes SQL-655 (2026 Refactor penetration test, finding H02, High).

### Motivation

`CREATE TABLE ... FROM SOURCE` bypassed source authorization: it required only
`CREATE` on the destination schema, and nothing about the source it reads.
Because the new 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

`CREATE TABLE ... FROM SOURCE` now requires `SELECT` on the source and `USAGE`
on its schema, the same read privileges `CREATE SINK` requires 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
table yet, and including columns some existing table omitted. If a reference
should not be reachable, it should not be in the source's publication or topic
set.

Pentest remediation item 2 (additionally requiring `SELECT` on an existing table
for the same reference) is intentionally not implemented: making the outcome
depend on which other tables happen to exist would let one team's migration
revoke another team's ability to run an unchanged statement. Per-column
granularity belongs in column-level grants.

### 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`: 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; and a second role with the same
grants can attach the same reference after another team has already attached it.

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, and its gate covers secrets, connections
and types but not sources. The upstream connection also comes from the source's
own definition rather than the statement, so an unauthorized role can still
cause an outbound connection to the source's upstream using the source owner's
credentials, and use purification errors as an upstream existence and
column-name oracle. Shared with `CREATE SOURCE` and `CREATE SINK` rather than
introduced here; closing it needs a pre-purification privilege gate. Filed
separately.
@tonydu-mz
tonydu-mz force-pushed the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch from 1bde35e to bdb9f10 Compare August 27, 2026 15:49
@def-

def- commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The new SELECT-on-source gate runs after purification, so an unauthorized role still drives the source's upstream connection

src/adapter/src/coord/command_handler.rs:1618

CREATE TABLE ... FROM SOURCE is purified off-thread before it is planned, and the only privilege check that runs before purification requires nothing for this statement. A role holding no privilege on the source, its schema, or the connection behind it can therefore still make Materialize open an upstream Postgres/MySQL/SQL Server connection with the source's secrets and probe upstream schema, table, and column names through purification errors. The new SELECT requirement only fires after all of that has happened.

Details

must_spawn_purification includes Statement::CreateTableFromSource (command_handler.rs:1828). The pre-purification rbac::check_usage(..., &CREATE_ITEM_USAGE) at command_handler.rs:1618 only demands USAGE on Secret/Connection/Type items among the unpurified statement's resolved_ids, and for this statement those ids are just the source, a Source. So nothing is required. purify_create_table_from_source (src/sql/src/pure.rs:1759) then resolves the source, takes its source_desc, and for Postgres calls pg_connection.validate(...) (pure.rs:1861) followed by get_source_references() (pure.rs:1869); the MySQL and SQL Server paths are equivalent.

Purification errors are returned to the client before planning (src/adapter/src/coord/message_handler.rs:828), and check_plan runs only from sequence_plan (src/adapter/src/coord/sequencer.rs:205). The caller can therefore distinguish reference to X not found in source (ExternalReferenceResolutionError::DoesNotExist) from the new permission-denied, which is an existence oracle for upstream schemas and tables; TEXT COLUMNS/EXCLUDE COLUMNS validation raises PlanError::UnknownColumn (src/sql/src/pure/postgres.rs:101), giving the same oracle for column names. Each probe also costs a real upstream connection.

The asymmetry this leaves is worth calling out: CREATE SOURCE ... FROM POSTGRES CONNECTION c names c, so check_usage does require USAGE on it before the connection is dialed. Reaching the same connection through an existing source names nothing, so nothing is required.

The fix is to mirror the plan-level requirement at the pre-purification site: for Statement::CreateTableFromSource, resolve the named source and require SELECT on it (plus USAGE on its schema) before spawning purification. That call site exists for precisely this reason, as its own comment says: "purification happens before planning, which may require the use of some connections and secrets."

@tonydu-mz

Copy link
Copy Markdown
Contributor Author

QA LLM Review

1. MEDIUM -- The new SELECT-on-source gate runs after purification, so an unauthorized role still drives the source's upstream connection

src/adapter/src/coord/command_handler.rs:1618

CREATE TABLE ... FROM SOURCE is purified off-thread before it is planned, and the only privilege check that runs before purification requires nothing for this statement. A role holding no privilege on the source, its schema, or the connection behind it can therefore still make Materialize open an upstream Postgres/MySQL/SQL Server connection with the source's secrets and probe upstream schema, table, and column names through purification errors. The new SELECT requirement only fires after all of that has happened.

Details

I'm aware of this and per my conversation with @jasonhernandez . This can be done as a follow up

@jasonhernandez

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The new SELECT-on-source gate runs after purification, so an unauthorized role still drives the source's upstream connection

src/adapter/src/coord/command_handler.rs:1618

CREATE TABLE ... FROM SOURCE is purified off-thread before it is planned, and the only privilege check that runs before purification requires nothing for this statement. A role holding no privilege on the source, its schema, or the connection behind it can therefore still make Materialize open an upstream Postgres/MySQL/SQL Server connection with the source's secrets and probe upstream schema, table, and column names through purification errors. The new SELECT requirement only fires after all of that has happened.
Details

must_spawn_purification includes Statement::CreateTableFromSource (command_handler.rs:1828). The pre-purification rbac::check_usage(..., &CREATE_ITEM_USAGE) at command_handler.rs:1618 only demands USAGE on Secret/Connection/Type items among the unpurified statement's resolved_ids, and for this statement those ids are just the source, a Source. So nothing is required. purify_create_table_from_source (src/sql/src/pure.rs:1759) then resolves the source, takes its source_desc, and for Postgres calls pg_connection.validate(...) (pure.rs:1861) followed by get_source_references() (pure.rs:1869); the MySQL and SQL Server paths are equivalent.

Purification errors are returned to the client before planning (src/adapter/src/coord/message_handler.rs:828), and check_plan runs only from sequence_plan (src/adapter/src/coord/sequencer.rs:205). The caller can therefore distinguish reference to X not found in source (ExternalReferenceResolutionError::DoesNotExist) from the new permission-denied, which is an existence oracle for upstream schemas and tables; TEXT COLUMNS/EXCLUDE COLUMNS validation raises PlanError::UnknownColumn (src/sql/src/pure/postgres.rs:101), giving the same oracle for column names. Each probe also costs a real upstream connection.

The asymmetry this leaves is worth calling out: CREATE SOURCE ... FROM POSTGRES CONNECTION c names c, so check_usage does require USAGE on it before the connection is dialed. Reaching the same connection through an existing source names nothing, so nothing is required.

The fix is to mirror the plan-level requirement at the pre-purification site: for Statement::CreateTableFromSource, resolve the named source and require SELECT on it (plus USAGE on its schema) before spawning purification. That call site exists for precisely this reason, as its own comment says: "purification happens before planning, which may require the use of some connections and secrets."

let's keep this open as a follow up, but merge what we have today!

@tonydu-mz
tonydu-mz merged commit 4662be2 into main Aug 27, 2026
88 checks passed
@tonydu-mz
tonydu-mz deleted the tonydu/sql-655-create-table-from-source-bypasses-source-authorization-h02 branch August 27, 2026 16:15
tonydu-mz added a commit that referenced this pull request Sep 2, 2026
Fixes SQL-660.

### Motivation

`CREATE TABLE ... FROM SOURCE` and `ALTER SOURCE` are purified off-thread
before they are planned. The only privilege check that ran before purification
was `rbac::check_usage(.., &CREATE_ITEM_USAGE)`, which requires `USAGE` on the
`Secret`, `Connection` and `Type` items the statement names. These statements
name neither a secret nor a connection: they reach the upstream through an
existing source, whose connection comes from its own `source_desc()` rather
than from the statement. So nothing was required.

Purification then opens that connection with the source owner's credentials and
enumerates upstream objects. A role holding no privilege on the source could
therefore make Materialize dial the source's upstream, and read upstream schema,
table and column names out of the resulting purification errors. SQL-655
(#38480) closed the plan-time bypass, so no rows are readable; this is the
residual that fix named.

`CREATE SOURCE ... FROM CONNECTION` and `CREATE SINK ... INTO` are unaffected:
they name their connection, so the existing usage requirement gates them.

### Description

Replace the pre-purification `check_usage` call with `rbac::check_purification`,
which builds a single `RbacRequirements` for the statement and delegates to the
same validation path `check_plan` uses:

* the existing `CREATE_ITEM_USAGE` usage requirements, unchanged;
* for `ALTER SOURCE`: ownership of the named source;
* for `CREATE TABLE ... FROM SOURCE`: read privileges on the source (`SELECT`
  plus schema `USAGE`), required of the owner too, since an owner's `SELECT` is
  an ordinary revocable grant and schema `USAGE` is separate from ownership.

Both mirror what planning requires later, so a statement that passes here can
still be rejected by `check_plan`, never the reverse.

The source is resolved from the statement rather than from `resolved_ids`.
`AlterSourceStatement::source_name` is an `UnresolvedItemName`, so name
resolution never records it and a `resolved_ids`-based check is silently a no-op
for `ALTER SOURCE`. Resolution mirrors purification exactly, so the check gates
the item that would be dialed.

The resolved source id is also added to the purified statement's dependency set:
it was previously absent for `ALTER SOURCE`, so a source dropped concurrently
with off-thread purification was not detected as invalidating the result.

### Verification

`test/sqllogictest/rbac_create_table_from_source.slt` gains cases that pin the
ordering, not just the denial. Each uses a statement whose purification fails
for a non-permission reason, so a permission error can only come from the
pre-purification check:

* `CREATE TABLE ... FROM SOURCE` with an unresolvable reference: purification
  reports whether a reference exists upstream, so without the gate this leaks
  reference existence.
* The same statement run by the source's owner after their `SELECT` is revoked,
  pinning that ownership does not stand in for read privileges.
* `ALTER SOURCE ... ADD SUBSOURCE` on a load generator, which purification
  rejects with "does not support ALTER SOURCE": an ownership error can only
  come from the earlier gate.

Plus an owner-succeeds case, so the rule gates the caller rather than the
syntax.
tonydu-mz added a commit that referenced this pull request Sep 2, 2026
Fixes SQL-660.

### Motivation

`CREATE TABLE ... FROM SOURCE` and `ALTER SOURCE` are purified off-thread
before they are planned. The only privilege check that ran before purification
was `rbac::check_usage(.., &CREATE_ITEM_USAGE)`, which requires `USAGE` on the
`Secret`, `Connection` and `Type` items the statement names. These statements
name neither a secret nor a connection: they reach the upstream through an
existing source, whose connection comes from its own `source_desc()` rather
than from the statement. So nothing was required.

Purification then opens that connection with the source owner's credentials and
enumerates upstream objects. A role holding no privilege on the source could
therefore make Materialize dial the source's upstream, and read upstream schema,
table and column names out of the resulting purification errors. SQL-655
(#38480) closed the plan-time bypass, so no rows are readable; this is the
residual that fix named.

`CREATE SOURCE ... FROM CONNECTION` and `CREATE SINK ... INTO` are unaffected:
they name their connection, so the existing usage requirement gates them.

### Description

Replace the pre-purification `check_usage` call with `rbac::check_purification`,
which builds a single `RbacRequirements` for the statement and delegates to the
same validation path `check_plan` uses:

* the existing `CREATE_ITEM_USAGE` usage requirements, unchanged;
* for `ALTER SOURCE`: ownership of the named source;
* for `CREATE TABLE ... FROM SOURCE`: read privileges on the source (`SELECT`
  plus schema `USAGE`), required of the owner too, since an owner's `SELECT` is
  an ordinary revocable grant and schema `USAGE` is separate from ownership.

Both mirror what planning requires later, so a statement that passes here can
still be rejected by `check_plan`, never the reverse.

The source is resolved from the statement rather than from `resolved_ids`.
`AlterSourceStatement::source_name` is an `UnresolvedItemName`, so name
resolution never records it and a `resolved_ids`-based check is silently a no-op
for `ALTER SOURCE`. Resolution mirrors purification exactly, so the check gates
the item that would be dialed.

The resolved source id is also added to the purified statement's dependency set:
it was previously absent for `ALTER SOURCE`, so a source dropped concurrently
with off-thread purification was not detected as invalidating the result.

### Verification

`test/sqllogictest/rbac_create_table_from_source.slt` gains cases that pin the
ordering, not just the denial. Each uses a statement whose purification fails
for a non-permission reason, so a permission error can only come from the
pre-purification check:

* `CREATE TABLE ... FROM SOURCE` with an unresolvable reference: purification
  reports whether a reference exists upstream, so without the gate this leaks
  reference existence.
* The same statement run by the source's owner after their `SELECT` is revoked,
  pinning that ownership does not stand in for read privileges.
* `ALTER SOURCE ... ADD SUBSOURCE` on a load generator, which purification
  rejects with "does not support ALTER SOURCE": an ownership error can only
  come from the earlier gate.

Plus an owner-succeeds case, so the rule gates the caller rather than the
syntax.
tonydu-mz added a commit that referenced this pull request Sep 3, 2026
Fixes SQL-660.

### Motivation

`CREATE TABLE ... FROM SOURCE` and `ALTER SOURCE` are purified off-thread
before they are planned. The only privilege check that ran before purification
was `rbac::check_usage(.., &CREATE_ITEM_USAGE)`, which requires `USAGE` on the
`Secret`, `Connection` and `Type` items the statement names. These statements
name neither a secret nor a connection: they reach the upstream through an
existing source, whose connection comes from its own `source_desc()` rather
than from the statement. So nothing was required.

Purification then opens that connection with the source owner's credentials and
enumerates upstream objects. A role holding no privilege on the source could
therefore make Materialize dial the source's upstream, and read upstream schema,
table and column names out of the resulting purification errors. SQL-655
(#38480) closed the plan-time bypass, so no rows are readable; this is the
residual that fix named.

`CREATE SOURCE ... FROM CONNECTION` and `CREATE SINK ... INTO` are unaffected:
they name their connection, so the existing usage requirement gates them.

### Description

Replace the pre-purification `check_usage` call with `rbac::check_purification`,
which builds a single `RbacRequirements` for the statement and delegates to the
same validation path `check_plan` uses:

* the existing `CREATE_ITEM_USAGE` usage requirements, unchanged;
* for `ALTER SOURCE`: ownership of the named source;
* for `CREATE TABLE ... FROM SOURCE`: read privileges on the source (`SELECT`
  plus schema `USAGE`), required of the owner too, since an owner's `SELECT` is
  an ordinary revocable grant and schema `USAGE` is separate from ownership.

Both mirror what planning requires later, so a statement that passes here can
still be rejected by `check_plan`, never the reverse.

The source is resolved from the statement rather than from `resolved_ids`.
`AlterSourceStatement::source_name` is an `UnresolvedItemName`, so name
resolution never records it and a `resolved_ids`-based check is silently a no-op
for `ALTER SOURCE`. Resolution mirrors purification exactly, so the check gates
the item that would be dialed.

The resolved source id is also added to the purified statement's dependency set.
It was previously absent for `ALTER SOURCE`, so a source dropped concurrently
with off-thread purification passed the validity check and then panicked the
coordinator on the missing catalog entry ("catalog out of sync") during
planning. With the id tracked, the drop is detected and the statement is
repurified, ending in a clean unknown-item error.

### Verification

`test/sqllogictest/rbac_create_table_from_source.slt` gains cases that pin the
ordering, not just the denial. Each uses a statement whose purification fails
for a non-permission reason, so a permission error can only come from the
pre-purification check:

* `CREATE TABLE ... FROM SOURCE` with an unresolvable reference: purification
  reports whether a reference exists upstream, so without the gate this leaks
  reference existence.
* The same statement run by the source's owner after their `SELECT` is revoked,
  pinning that ownership does not stand in for read privileges.
* `ALTER SOURCE ... ADD SUBSOURCE` on a load generator, which purification
  rejects with "does not support ALTER SOURCE": an ownership error can only
  come from the earlier gate.

Plus: an owner-succeeds case, so the rule gates the caller rather than the
syntax; a schema-`USAGE` denial, pinning the other half of the read
requirement; and pass-through cases for a superuser and for an RBAC-disabled
deployment, each reaching purification's own error on a source they do not
own, pinning that the gate filters requirements the same way `check_plan`
does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Critical issue that should block *any* release if not fixed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants