Skip to content

fix(sql): refuse a destination credential that cannot write - #811

Merged
paulkarayan merged 5 commits into
mainfrom
pk/sql-insert-permission-probe
Sep 21, 2026
Merged

paulkarayan merged 5 commits into
mainfrom
pk/sql-insert-permission-probe

Conversation

@paulkarayan

@paulkarayan paulkarayan commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

What changes

The SQL destination precheck was SELECT 1. That runs against the session, so it succeeds on any credential the driver can open a connection with and says nothing about the table the uploader writes to. A credential holding CONNECT, USAGE and SELECT and no INSERT passed setup and then failed on every record at write time.

Precheck now asks the question, using the real statements made harmless. The probe's privilege surface has to match the upload's exactly -- wider refuses a credential that works, narrower passes one that cannot write -- and upload_dataframe is delete-then-insert, so it is both:

INSERT INTO <table> (<columns>) SELECT <columns> FROM <table> WHERE 1 = 0
DELETE FROM <table> WHERE 1 = 0

The DELETE runs only when can_delete() is true, the same gate upload_dataframe puts its own DELETE behind, so a table with no record-id column is never asked for a right the upload does not use. The engine runs the privilege check when it plans the statement, before any row is produced, so a refusal arrives while the row count is still zero and nothing is written or removed even where the driver is in autocommit.

The columns are get_table_columns(), not *, because that is what the uploader's own INSERT names once _fit_to_schema has conformed the frame. Grants are per-column on every engine here, so a probe asking for rights the write path does not use would refuse credentials that work.

The message names the right that was refused. A credential that can insert and cannot delete is told it needs DELETE, with no mention of the INSERT it holds. Both probes run even when the first is refused, so a credential short of both is told both at once.

One-sided by construction: it refuses only on an answer the dialect recognizes as an unambiguous privilege denial, and passes on a timeout, a dropped connection, a missing table, a schema error, an unrecognized code, a dialect with no classifier, or an exception from the classifier itself. Codes meaning "does not exist OR you may not see it" (postgres 42P01, teradata 3807, snowflake 002003) are excluded, so a typo'd table name is never reported as a permissions problem.

The schema read the probes are built from is held to the same contract. get_table_columns() runs a SELECT * FROM <table> LIMIT 1, which the write path runs too, so a credential refused there cannot upload; that refusal is now classified rather than swallowed, and a credential denied even SELECT is refused at precheck instead of passing it. Teradata is deliberately left out of that one: its 3807 means "does not exist or you may not see it", and a configured table that does not exist yet is a working Teradata destination because create_destination() builds it at upload time.

A refusal is a UserError (422), never a UserAuthError -- the credential is valid, and telling the customer to rotate it sends them to fix the wrong thing.

Two dialects used to log a denial with no code to read. sqlite_errorcode now crosses the safe-attribute allowlist, and TeradataUploader appends teradata_error=NNNN through a new _probe_error_detail seam. Only the parsed code crosses, never the message it came from.

Covers postgres, singlestore, sqlite, snowflake and teradata. databricks-delta-tables, vastdb and ibm-watsonx-s3 define their own precheck and are untouched.

What it does not cover

Two gaps, both in the direction a one-sided check is allowed to be wrong in: they pass here and can still fail at upload.

  • Teradata's create_destination() needs CREATE TABLE, which this does not probe. It is reached only when the configured table does not exist yet, in which case the schema read fails first and the probe reports unknown and passes. Probing a create right is not side-effect free and is a different problem from probing rights on a table that already exists.
  • Per-row rules are invisible to a zero-row statement: a postgres row-level-security WITH CHECK policy and a Snowflake row access policy are both evaluated per row, so neither is reached.

Proof

Reproduced on local postgres 16.15 with roles granted exactly one scenario each. Before:

reader    precheck=PASS  | real upload=FAILED: InsufficientPrivilege(pgcode=42501)
inserter  precheck=PASS  | real upload=FAILED: InsufficientPrivilege(pgcode=42501)
writer    precheck=PASS  | real upload=WROTE OK
colgrant  precheck=PASS  | real upload=WROTE OK

After:

noaccess     precheck -> REFUSED UserError(422): ...do not have SELECT permission on table 'elements'...
reader       precheck -> REFUSED UserError(422): ...INSERT permission... ...DELETE permission...
inserter     precheck -> REFUSED UserError(422): ...DELETE permission on table 'elements'...
ro_endpoint  precheck -> REFUSED UserError(422): ...the connection is read-only...  (pgcode=25006)
writer       precheck -> PASS
colgrant     precheck -> PASS
rows left behind by all five roles' probes: 0

inserter holds INSERT and not DELETE: it fails every record because the write path deletes first, and it is now refused for DELETE specifically. writer and colgrant are the rows that did not move -- colgrant holds column-level grants only, runs the real upload fine, and is not refused.

SingleStore was exercised live against a real server (errno 1142 for both a refused INSERT and a refused DELETE, zero rows left); SQLite against a read-only database file and against a table with no record-id column, where the DELETE probe is correctly skipped.

Snowflake and Teradata are a WAIVED environment leg: both signals come from vendor documentation and the drivers' own source and are unit-tested, but no Snowflake account or Teradata instance is reachable from this machine.

Dependencies / merge order

none

Testing

  • 1818 unit tests pass (make test-unit). In a CI-shaped venv with all four SQL drivers absent, test/unit is 1740 passed / 69 skipped with no collection errors, which is the group that was red before the rebase. test_postgres_destination_precheck_refuses_a_credential_that_cannot_write passes, fails on origin/main with DID NOT RAISE UserError, and fails with the DELETE leg alone removed.
  • ruff check . clean.

Review in cubic

@paulkarayan
paulkarayan requested a review from a team as a code owner September 12, 2026 03:58
@paulkarayan

Copy link
Copy Markdown
Contributor Author

Repro-first proof — WAIVED (environment)

This proof was NOT completed. It passed the gate on a stated reason.

Waived (environment): WAIVED: the Snowflake and Teradata legs. Both denial signals are implemented from vendor
documentation and from the driver's own source, and both are covered by unit tests, but
neither was exercised against a live server because no Snowflake account and no Teradata
instance is reachable from this machine. Postgres, SingleStore and SQLite were all run
live against real servers.

Snowflake: error 003001 / SQLSTATE 42501, "SQL access control error: Insufficient
privileges to operate on ". Confirmed from Snowflake's own KB articles and from
third-party transcripts quoting ERROR [42501] SQL access control error: Insufficient privileges to operate on stage; the attribute names come from
snowflake/connector/errors.py, where Error.init sets self.errno and self.sqlstate.

Teradata: codes 3523 / 5315 / 5612, read out of the message by the parser this repo
already ships and already trusts on the upload path (_extract_teradata_error_code, and
the same three codes already sit in _USER_FAULT_TERADATA_CODES with these meanings).
teradatasql.Error is a bare Exception subclass with no code attribute, so there is no
structured field to read instead.

What would unblock it: a Snowflake account and a Teradata instance (the customer DI, or
a Teradata Vantage Express container) with three roles each -- SELECT only,
SELECT+INSERT, and SELECT+INSERT+DELETE -- and a run of the same before/after probe.
That is the run still owed, and it is the only way to find out whether either engine
reports something other than the documented code, and whether it reports the same code
for a refused DELETE as for a refused INSERT (postgres and SingleStore both do, measured).

Bugfix — SQL destination precheck asks whether the credential can perform the write, not just connect

Reproduced the broken state

  • Environment: local
  • How: Local PostgreSQL 16.15 (homebrew) on :55432, database ingest_probe with an elements
    table and three roles granted exactly one scenario each:
    reader -> CONNECT + USAGE + SELECT (the Pylon 2730 credential)
    inserter -> CONNECT + USAGE + SELECT + INSERT, NO DELETE
    writer -> CONNECT + USAGE + SELECT/INSERT/DELETE
    colgrant -> CONNECT + USAGE, column-level SELECT+INSERT on every column, table DELETE
    Then, on origin/main (6528db0), for each role: PostgresUploader.precheck() followed by
    the uploader's real write path, PostgresUploader.upload_dataframe().
  • Observed: reader precheck=PASS | real upload=FAILED: DestinationConnectionError: failed to upload: InsufficientPrivilege(pgcode=42501)
    writer precheck=PASS | real upload=WROTE OK
    colgrant precheck=PASS | real upload=WROTE OK

The precheck is cursor.execute("SELECT 1;"), which runs against the session, so it
passes on any credential the driver can open a connection with. The read-only role is
indistinguishable from the writable one at setup and fails on every record at write time.

Second repro, run against the first version of this fix (an INSERT-only probe), which
shows why the probe has to cover the whole write path and not just the insert:

inserter precheck=PASS | real upload=FAILED: DestinationConnectionError: failed to upload: InsufficientPrivilege(pgcode=42501)
writer precheck=PASS | real upload=WROTE OK

upload_dataframe is delete-then-insert (sql.py:434 gates the DELETE on can_delete(),
sql.py:492 issues it), so a credential holding INSERT and no DELETE passed an
insert-only probe and still failed every record. A probe narrower than the statement it
stands in for passes a credential that cannot write.

  • Evidence: /tmp/claude-502/proof/repro.py

Failing test (red)

  • test/integration/connectors/sql/test_postgres.py::test_postgres_destination_precheck_refuses_a_credential_that_cannot_write (integration) — committed
  • transcribed by the author (not captured by a runner)
(a) On a detached worktree at origin/main (6528db05) with only the new test files copied in:

  E           Failed: DID NOT RAISE <class 'unstructured_ingest.error.UserError'>
  test/integration/connectors/sql/test_postgres.py: Failed
  FAILED test/integration/connectors/sql/test_postgres.py::test_postgres_destination_precheck_refuses_a_credential_that_cannot_write
  FAILED test/unit/connectors/sql/test_postgres.py::test_insufficient_privilege_is_a_denial
  ... 10 failed in 37.95s

(b) With the DELETE leg alone removed from the fix (the `if deletes:` append commented
out), to show the delete half of the test is load-bearing and not decorative:

  >           assert "DELETE permission on table 'elements'" in str(reader_refusal.value)
  E           assert "DELETE permission on table 'elements'" in "The destination credentials can connect to the database but do not have INSERT permission on table 'elements'. Records would fail to write. Grant INSERT on that table to the user this connector authenticates as."
  FAILED test/integration/connectors/sql/test_postgres.py::test_postgres_destination_precheck_refuses_a_credential_that_cannot_write
  1 failed in 35.27s

  and, in the unit suite, 7 failed / 249 passed:
  FAILED test_sql.py::test_write_probes_cover_insert_and_delete_and_touch_no_rows
  FAILED test_sql.py::test_every_probe_rolls_back_whether_or_not_the_statement_raised
  FAILED test_sql.py::test_a_confirmed_denial_names_the_right_that_was_refused[denied1-no DELETE for you]
  FAILED test_sql.py::test_a_confirmed_denial_names_the_right_that_was_refused[denied2-no INSERT for you no DELETE for you]
  FAILED test_teradata.py::test_teradata_uploader_precheck_success
  FAILED test_teradata.py::test_teradata_write_probes_quote_every_identifier
  FAILED test_teradata.py::test_teradata_precheck_refuses_a_credential_that_cannot_delete

Fix

  • SQLUploader.precheck() now calls check_write_permissions(), which runs two zero-row,
    rolled-back probes covering the whole write path -- an INSERT naming the uploader's own
    column list, and a DELETE behind the same can_delete() gate upload_dataframe puts its
    DELETE behind -- and refuses only on a driver answer the dialect recognizes as an
    unambiguous privilege denial. The message names the right that was actually refused.
    Every other outcome is unknown and passes. Per-dialect classifiers for postgres,
    singlestore, sqlite, snowflake and teradata; teradata calls the probe from its own
    precheck override.
  • Files: unstructured_ingest/processes/connectors/sql/sql.py, unstructured_ingest/processes/connectors/sql/postgres.py, unstructured_ingest/processes/connectors/sql/singlestore.py, unstructured_ingest/processes/connectors/sql/sqlite.py, unstructured_ingest/processes/connectors/sql/snowflake.py, unstructured_ingest/processes/connectors/sql/teradata.py

Proof it's resolved

  • Test green: yes
  • Environment: local
  • Evidence: ``Same probe, same postgres roles, before and after. ro_endpoint (full grants,
    default_transaction_read_only=on) was added after the repro to exercise the second
    denial code and has no before row.

BEFORE (origin/main, and -- for inserter -- the insert-only first version of the fix)
reader precheck=PASS | real upload=FAILED: InsufficientPrivilege(pgcode=42501)
inserter precheck=PASS | real upload=FAILED: InsufficientPrivilege(pgcode=42501)
writer precheck=PASS | real upload=WROTE OK
colgrant precheck=PASS | real upload=WROTE OK

AFTER (this branch)
reader precheck -> REFUSED UserError(422): ...do not have INSERT permission on table 'elements'... ...do not have DELETE permission on table 'elements'...
inserter precheck -> REFUSED UserError(422): ...do not have DELETE permission on table 'elements'...
ro_endpoint precheck -> REFUSED UserError(422): ...the connection is read-only... (pgcode=25006)
writer precheck -> PASS
colgrant precheck -> PASS
rows left behind by all five roles' probes: 0

Three things to read off it. inserter is the round-2 defect: it can insert, the upload
deletes first, and it is now refused -- and refused for DELETE specifically, not
accused of lacking INSERT it actually holds. reader is short of both rights and is
told both in one message rather than learning about DELETE after granting INSERT. And
the two rows that did NOT move are the point: writer and colgrant both still pass.
colgrant holds column-level grants only and no table-level INSERT, runs the real upload
fine, and is not refused -- that is the false refusal a wider probe would have produced.

Statement shapes measured directly on postgres 16, to confirm the zero-row DELETE still
runs the privilege check:
inserter 'DELETE FROM "elements" WHERE 1 = 0' -> InsufficientPrivilege(pgcode=42501)
reader 'DELETE FROM "elements" WHERE 1 = 0' -> InsufficientPrivilege(pgcode=42501)
writer 'DELETE FROM "elements" WHERE 1 = 0' -> PASS (rowcount=0)
colgrant 'DELETE FROM "elements" WHERE 1 = 0' -> PASS (rowcount=0)
writer in READ ONLY txn -> ReadOnlySqlTransaction(pgcode=25006)
writer missing table -> UndefinedTable(pgcode=42P01) [unknown, passes]

Two other dialects were exercised live on real servers, same shape:

SingleStore (ghcr.io/singlestore-labs/singlestoredb-dev on :33306, real SingleStore)
reader SELECT 1 PASS | INSERT=errno 1142 "INSERT command denied to user 'reader'@'%' for table 'elements'" | DELETE=errno 1142 "DELETE command denied to user 'reader'@'%' for table 'elements'"
inserter SELECT 1 PASS | INSERT=PASS(rowcount=0) | DELETE=errno 1142 "DELETE command denied to user 'inserter'@'%' for table 'elements'"
writer SELECT 1 PASS | INSERT=PASS(rowcount=0) | DELETE=PASS(rowcount=0)
rows left behind: 0
through the connector: reader -> REFUSED (INSERT and DELETE); inserter -> REFUSED (DELETE only); writer -> PASS

SQLite (a writable .db, the same .db chmod 444, and one with no record_id column)
sqlite rw.db can_delete=True precheck -> PASS
sqlite nodelete.db can_delete=False precheck -> PASS [INSERT probe only; upload skips the DELETE there]
sqlite ro.db can_delete=True precheck -> REFUSED UserError: ...is read-only...
raw INSERT: OperationalError sqlite_errorcode=8 SQLITE_READONLY
raw DELETE: OperationalError sqlite_errorcode=8 SQLITE_READONLY

Suites:
1784 passed (pytest -n auto test/unit --ignore test/unit/unstructured)
9 passed (test/integration/connectors/sql/test_postgres.py + test_sqlite.py, docker)
ruff check . -> All checks passed!``


Auto-generated from this branch's .proof.toml (repro-first proof gate). Advisory.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 15 files

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread unstructured_ingest/processes/connectors/sql/snowflake.py
Comment thread unstructured_ingest/processes/connectors/sql/sql.py
Comment thread unstructured_ingest/processes/connectors/sql/sql.py
@paulkarayan paulkarayan added prio:need Blocking / committed -- a customer or release depends on it and removed prio:need Blocking / committed -- a customer or release depends on it labels Sep 14, 2026
@paulkarayan paulkarayan added the prio:need Blocking / committed -- a customer or release depends on it label Sep 21, 2026

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

Reviewed this end to end. Requesting changes on one item specifically — the Snowflake regression needs validating before this merges — plus the red CI. Details are inline; everything non-blocking is in a separate comment so it doesn't clutter the merge path.

First, the part that deserves saying: the design here is the strong part of this PR, and all three blockers are mechanical slips rather than problems with the approach.

The one-sided contract is genuinely enforced rather than just asserted — test_write_probes_pass_on_anything_they_cannot_confirm, test_write_probes_pass_when_the_connection_cannot_be_opened, and test_write_probes_pass_when_can_delete_cannot_be_answered (test_sql.py:601-631) are exactly the tests that stop this from becoming a false-refusal generator. The docstring at sql.py:413 cites test_write_probe_columns_match_the_uploaders_insert and that test actually exists at test_sql.py:516. And the postgres integration test (test_postgres.py:239-289) stands up four real roles, asserts the reader is told about both missing rights in one message, asserts the inserter is told DELETE and specifically not INSERT, then asserts count(*) == 0 to prove the probes wrote nothing — that's the test a reviewer would have asked for, already written. I'm not re-litigating any of the documented exclusions (42P01, 3807, 002003, 1045); each one gives its reason.

The ask, specifically on the Snowflake regression:

SnowflakeUploader lost its _embeddings_dimension and _variant_columns dataclass fields in a725337e, where classify_write_denial was inserted in their place. Both properties still read them, so every freshly constructed uploader raises AttributeError on its first batch. This is unrelated to the PR's actual purpose, which is what makes me fairly confident it's an editing slip.

What I'd like before merge is a validation that closes the hole permanently, not just the two lines restored:

  1. Restore both field declarations.
  2. Add a test that constructs a SnowflakeUploader and reads variant_columns / embeddings_dimension without the fixture's manual assignment. The fixture at test_snowflake.py:42-43 sets both by hand — that's pre-existing on main, not something this PR introduced, but it's the reason 300+ lines of Snowflake tests stayed green through a total breakage of the write path. As long as every test goes through that fixture, this regression can recur silently.

Worth flagging that the new precheck passes cleanly in this state — check_write_permissions never touches those properties — so the failure only surfaces once real records start writing.

Also blocking: test_ingest_unit is red because three new test files import optional connector drivers that aren't in the base test group. Two fail at collection, one fails at call time; inline threads have the specifics and point at the pytest.importorskip / fake-exception patterns already used elsewhere in the repo (including in this PR's own test_teradata.py).

Happy to re-review quickly once the Snowflake fields are back and CI is green.

Comment thread unstructured_ingest/processes/connectors/sql/snowflake.py
Comment thread test/unit/connectors/sql/test_postgres.py Outdated
Comment thread test/unit/connectors/sql/test_singlestore.py Outdated
Comment thread test/unit/connectors/sql/test_snowflake.py Outdated
@tabossert

Copy link
Copy Markdown
Contributor

Non-blocking observations from the same review pass, separated out so they don't clutter the merge path. Several of these are questions rather than requests, and a couple are things I'd be happy to be told are deliberate.


The branch needs a rebase, and the version bump is stale

origin/main is now at 1.11.15; this branch bumps to 1.11.13, which #794 already consumed (main's ## [1.11.13] is the databricks-volumes precheck change). Three commits have landed since the merge base: 23c52278 (#794), f9591663 (#812), 5d7f3347 (#813). gh pr view reports mergeable: CONFLICTING. After rebasing, both __version__.py and the CHANGELOG heading want 1.11.16.

One caveat so this isn't confusing: the check-version badge showing PASS is not evidence the version is fine. scripts/version-sync.sh only errors when the branch version exactly equals main's current version, so 1.11.13 vs 1.11.15 sails through by design — the gate simply doesn't cover a stale bump. (I initially had this wrong and assumed the check would go red on a re-run; it wouldn't.)

Also worth knowing: #812 reworked _USER_FAULT_TERADATA_CODES (adding 2621/2665/2666/5407/6706) and #813 touched the teradata destination tests, so the teradata conflict here is semantic, not just textual — _WRITE_DENIAL_TERADATA_CODES is derived from that map and is worth re-deriving against the post-#812 version rather than resolving line-by-line.

A credential denied even SELECT still passes the new check

get_table_columns() (sql.py:645-650) runs a bare SELECT * from {table} LIMIT 1 with no error handling of its own, and check_write_permissions catches that at sql.py:433-441, logs at INFO, and returns. So a credential with no access at all fails the schema read, gets logged as "schema unavailable", and precheck passes — the customer then gets the old generic DestinationConnectionError at upload time rather than the new named-privilege message.

The sharper version of this is on Teradata: get_table_columns (teradata.py:575-590) routes a 3523/5612/5315 schema-read failure through _raise_classified_teradata_error, which raises a UserError that has already correctly identified the denial — and sql.py:435's except Exception catches that and turns it into a skip. The one dialect that positively identifies the zero-access case is the one that discards the identification.

Not a regression, and I realise "the table schema is unavailable" genuinely isn't this method's question. But it does mean the fix covers a credential that can't write but can already read, which is narrower than the title suggests. Is routing an already-classified UserError through rather than swallowing it worth doing?

The three untouched connectors

You documented this in the CHANGELOG verbatim, so this isn't a gap you missed — it's a scope decision. Recording it only so it doesn't become permanent: databricks_delta_tables.py:145, vastdb.py:196, and ibm_watsonx_s3.py:346 each override precheck() without calling super().precheck(), and their checks are read/metadata-only (SHOW CATALOGS, table.select(), namespace_exists). I confirmed those are the only three of the eight SQLUploader subclasses that bypass the probe. Worth a follow-up issue so the three-connector hole doesn't outlive the memory of why it's there?

Two dialects log a denial with no error code

safe_error_summary reads only the _SAFE_ERROR_ATTRS name allowlist (error.py:12-23) and never message text — which is the right call, given driver text carries host/user/password.

  • Teradata: per your own note at teradata.py:555-557, teradatasql.Error is a bare Exception subclass carrying no code attribute; the code lives only in the [Error NNNN] message tag. So sql.py:482 and sql.py:484-486 both render as bare OperationalError.
  • SQLite: classify_write_denial correctly reads sqlite_errorcode at sqlite.py:162, but that attribute isn't in _SAFE_ERROR_ATTRS. I reproduced this against a real read-only DB using your exact INSERT probe: it raises OperationalError with sqlite_errorcode=8 (SQLITE_READONLY), and none of the allowlisted attributes are present — so the log drops the code.

The inconclusive line is the one that matters for debugging a false negative, and for these two dialects it's content-free. SQLite looks like a one-line fix: sqlite_errorcode is an int and a pure machine code, which fits the allowlist's stated contract. Teradata would need the already-extracted _extract_teradata_error_code value logged explicitly, since attribute sniffing structurally can't work there.

A crash inside a classifier looks identical to a clean non-match

At sql.py:477-480 the bare except Exception: reason = None discards the classifier's own exception entirely, and the log at sql.py:482 then reports e — the original driver error. So if classify_write_denial ever raises, the logs are indistinguishable from "driver error we don't recognise." A debug line naming the classifier's exception type would make a classifier defect diagnosable. (Correcting myself: there is a log line here — I'd originally noted there wasn't.)

Connection count at precheck

Each probe opens a fresh physical connection; there's no pooling in this library. Teradata goes from 1 connection at precheck to 4 (SELECT 1, schema read, INSERT probe, DELETE probe), each carrying an auth handshake plus the SET SESSION CHARACTER SET UNICODE PASS THROUGH ON round-trip. Fine for most deployments, but worth knowing if anyone reports slow job startup against a Teradata destination near its session ceiling.

Small related note: Teradata's get_connection calls connection.commit() in its own finally, immediately after the probe's connection.rollback() at sql.py:474. Harmless with zero rows, but the docstring's rollback reassurance is doing nothing on that dialect.

Postgres 25006 during a failover

Treating 25006 as an unambiguous refusal is correct in the moment it fires. But a connection routed briefly to a reader endpoint during an RDS/Aurora failover, or a pooler mid-promotion, would get a hard 422 from a one-shot check with no retry. I haven't measured this and it may well be rare enough not to matter — flagging it as a question rather than a claim.

Does the 422 reach the user?

The stated rationale for UserError over UserAuthError is that customers shouldn't be sent to rotate a working credential. Your integration test proves the UserError and its wording surface at the uploader boundary (test_postgres.py:273-289), so that part is demonstrated.

What I couldn't verify is the rest of the path: pipeline.py:142 is the only non-test caller of an uploader's precheck(), and pipeline.py:148-153 flattens every exception into PipelineError("Initialization failed"). The message survives as text in the log at pipeline.py:149, but the 422 on UserError (error.py:153) doesn't propagate. Does the platform call precheck() directly so the status code is preserved? I couldn't tell from this repo.

Was grant introspection considered?

The docstring is unusually thorough about tradeoffs, but never mentions the driver's own privilege introspection (has_table_privilege, SHOW GRANTS) — I grepped and it appears nowhere. There are good reasons to prefer a live probe (grant tables diverge from effective privilege under role composition and poolers), and it would sidestep the statement-level-trigger question cubic raised. Was it weighed and rejected, or just not on the list?

Keeping the two teradata code tables in sync

_USER_FAULT_TERADATA_CODES (teradata.py:73-84) and _WRITE_DENIAL_TERADATA_CODES (teradata.py:463-468) share both helpers, so this is one parsing idiom with two tables and two return contracts — and you document why 3807 belongs in one and not the other. Not asking you to consolidate them. Just: would a cross-reference comment on the first pointing at the second help keep them from drifting, especially now that #812 has already extended one of them?

Nit

sql.py:446-450 guards can_delete() with except Exception, but can_delete() only calls the already-memoized get_table_columns(), which succeeded at sql.py:434 or the method already returned. Is that defensive for a future override that queries independently?

paulkarayan and others added 5 commits September 21, 2026 09:35
The SQL destination precheck was `SELECT 1`. That runs against the session,
so it succeeds on any credential the driver can open a connection with and
says nothing about the table the uploader writes to. A credential holding
CONNECT, USAGE and SELECT and no INSERT passed setup and then failed on every
record at write time. Measured on postgres 16: precheck PASS, upload
InsufficientPrivilege(pgcode=42501).

Precheck now also asks the question, using the real statements made harmless.
The probe's privilege surface has to match the upload's exactly -- wider
refuses a credential that works, narrower passes one that cannot write --
and `upload_dataframe` is delete-then-insert, so it is both:

    INSERT INTO <table> (<columns>) SELECT <columns> FROM <table> WHERE 1 = 0
    DELETE FROM <table> WHERE 1 = 0

The DELETE runs only when `can_delete()` is true, which is the same gate
`upload_dataframe` puts its own DELETE behind: on a table with no record-id
column the upload skips the delete and warns, so asking for DELETE there would
refuse a credential that never needs it. The DELETE names no column on purpose
-- the real one filters on the record-id column, and the INSERT probe already
asks for SELECT on every column because `get_table_columns()` does.

The engine runs the privilege check when it plans the statement, before any
row is produced, so a refusal arrives while the row count is still zero and
nothing is written or removed even where the driver is in autocommit. `1 = 0`
rather than `FALSE` because Teradata has no boolean literal. Measured on
postgres 16: the zero-row DELETE returns rowcount=0 for a credential that
holds the right and 42501 for one that does not.

It lives in the base `SQLUploader` with a per-dialect `classify_write_denial`
hook, rather than as a per-connector probe. The statements, the column
derivation, the can_delete gate, the rollback, the one-sidedness and the raised
type are identical across dialects; the only thing that differs is which driver
answer counts as a refusal, and that is all a connector overrides. postgres,
singlestore, sqlite and snowflake inherit the call from the base precheck;
teradata calls it from its own precheck override and quotes identifiers the way
its uploader does. databricks-delta-tables, vastdb and ibm-watsonx-s3 define
their own precheck and are untouched: the first two do not write through the
column list this derives from, and none of the three has a verified denial code.

The columns are `get_table_columns()`, not `*`, because that is what the
uploader's own INSERT names once `_fit_to_schema` has conformed the frame.
Grants are per-column on every engine here, so a probe asking for rights the
write path does not use refuses credentials that work;
`test_write_probe_columns_match_the_uploaders_insert` pins the two together so
narrowing one without the other fails the suite.

The message names the right that was refused. A credential that can insert and
cannot delete is told it needs DELETE, with no mention of the INSERT it holds;
sending it to grant the wrong thing is its own bug. Both probes run even when
the first is refused, so a credential short of both is told both at once
instead of learning about the second after granting the first.

One-sided by construction. It refuses only on an answer the dialect recognizes
as an unambiguous privilege denial, and passes on everything else: a timeout, a
dropped connection, a missing table, a schema error, an unrecognized code, a
dialect with no classifier, an exception from the classifier itself. Codes that
mean "does not exist OR you may not see it" -- postgres 42P01, teradata 3807,
snowflake 002003 -- are deliberately excluded, so a typo'd table name is never
reported as a permissions problem.

Denial signals, and how each was established. postgres and singlestore both
report a refused DELETE with the same code as a refused INSERT, measured, which
is why the message's privilege comes from the probe and not from the code:

  postgres     42501 insufficient_privilege, 25006 read_only_sql_transaction,
               read off psycopg2's `pgcode` via `psycopg2.errorcodes`.
               Reproduced live on postgres 16.15 for INSERT and for DELETE.
  singlestore  errno 1044/1142/1143, the MySQL access-denied numbers
               SingleStore reuses. Reproduced live against a real SingleStore:
               "INSERT command denied to user 'reader'@'%' for table
               'elements'" and "DELETE command denied to user 'inserter'@'%'
               for table 'elements'", both errno 1142. 1045 is excluded -- that
               is authentication.
  sqlite       SQLITE_READONLY in the low byte of `sqlite_errorcode`.
               Reproduced live against a read-only database file, for both
               statements. SQLite has no grants, but the
               connect-and-read-then-cannot-write shape is the same and the
               signal is exact.
  snowflake    error 003001 / SQLSTATE 42501, from Snowflake's documented access
               control error; attribute names from the connector's own
               errors.py. Not exercised against a live account.
  teradata     3523/5315/5612, read out of the message by the `[Error NNNN]`
               parser this module already trusts on the upload path -- the
               driver's exceptions carry no code attribute. Not exercised
               against a live instance.

A refusal is a `UserError` (422), never a `UserAuthError`. The credential is
valid; telling the customer to rotate it sends them to fix the wrong thing.
Driver text is never surfaced -- it can carry the connection string.

Two rights stay outside what a zero-row statement can ask about, and both still
fail at upload: a rule evaluated per row -- postgres RLS `WITH CHECK`, a
Snowflake row access policy -- and the CREATE TABLE right Teradata's
create_destination() needs, which is reached only when the configured table does
not exist yet, in which case the schema read fails first and the probe reports
unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…top of

`classify_write_denial` was inserted exactly where `_embeddings_dimension` and
`_variant_columns` were declared, and both readers stayed: `variant_columns` and
`embeddings_dimension` each test their cached attribute before filling it. A freshly
constructed `SnowflakeUploader` raised `AttributeError: 'SnowflakeUploader' object has no
attribute '_embeddings_dimension'` on its first batch, so every Snowflake upload broke
while the new precheck, which never reads either property, passed cleanly first.

The fixture at test_snowflake.py:42-43 assigned both by hand. That predates this branch and
was redundant until the fields went away, at which point it became the reason 300 lines of
Snowflake tests stayed green through a broken write path. The fixture no longer sets them,
so every test in the file now runs against the fields' own defaults, and two tests cover
the regression directly: one constructs an uploader the way the pipeline does and reads
both properties, one asserts both fields are declared on the dataclass.

Removing the two fields again turns 10 tests in that file red, 8 of which the fixture used
to mask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…roup runs

`test_ingest_unit` was red. CI runs `uv sync --group test --locked`, which installs no
extras, and three of the new test modules reached for a driver that lives in one:
`psycopg2` and `singlestoredb` at module scope, which failed the whole file at collection,
and `snowflake.connector` inside a helper, which failed 7 tests at call time.

Two of the three now forge the driver error instead of importing the driver, which is what
`test_teradata.py` in this same branch already does with `_FakeTeradataDriverError`. Both
classifiers read exactly one thing through `getattr` and never the exception type:
singlestore reads `errno`, snowflake reads `sqlstate` and `errno`. The fakes carry those and
nothing else, and the snowflake fake mirrors the driver's own unset defaults (errno=-1,
sqlstate="n/a") so an unset field means the same thing in the test as in production. Each
one is paired with an `importorskip` test that asserts the real driver still fills those
fields, so the fake cannot quietly drift away from the thing it stands in for.

Postgres is skipped rather than forged: `PostgresUploader.classify_write_denial` is
`@requires_dependencies(["psycopg2"])` and reads the SQLSTATEs out of `psycopg2.errorcodes`,
so it cannot run without the driver at all. `pytest.importorskip` there matches
test_gcs.py's skip on gcsfs, and sits after the other imports to satisfy E402.

Verified against a venv built the way CI builds it (`uv sync --group test --locked`, all
four SQL drivers absent). Before: 2 collection errors and 7 failures. After: 269 passed,
3 skipped, nothing red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…either

The check covered a credential that can read and cannot write. A credential with no
rights at all failed the schema read instead, got logged as "schema unavailable", and
passed -- so the customer this is for, the one whose credential holds nothing useful,
still met the old generic upload failure a job later.

The schema read is `SELECT * FROM <table> LIMIT 1`, and the write path runs it too, both
to conform the frame and to name the columns of its own INSERT. A credential refused
there cannot upload. It is now classified through the same one-sided contract as the two
write probes: refuse only on an answer the dialect calls an unambiguous denial, pass on
everything else, including the table not existing yet.

Measured on postgres 16.15 against four roles granted exactly one scenario each:

  noaccess  precheck -> REFUSED (422): ...do not have SELECT permission on table 'elements'
  reader    precheck -> REFUSED (422): ...INSERT... ...DELETE...
  inserter  precheck -> REFUSED (422): ...DELETE...
  writer    precheck -> PASS
  writer, table name misspelled -> PASS

Teradata is deliberately not closed. Its `get_table_columns()` classifies the driver error
itself and raises `UserError`, so what arrives is already wrapped. Re-raising that
unconditionally is the obvious move and is wrong: 3807 means "object does not exist or
user has no privilege on it", and a configured table that does not exist yet is a working
Teradata destination, because `create_destination()` builds it at upload time. That would
refuse a destination that works today.

Also here, from the same review pass:

- `sqlite_errorcode` joins the `_SAFE_ERROR_ATTRS` allowlist. It is an int and a pure
  machine code, which is the allowlist's stated contract, and without it a refused SQLite
  write logged as a bare `OperationalError`: measured, the inconclusive line now carries
  `sqlite_errorcode=8`.
- Teradata renders the parsed `[Error NNNN]` code into the probe log. `teradatasql.Error`
  carries no code attribute at all, so attribute sniffing structurally cannot work there
  and its probe lines were content-free. Only the code crosses, never the message.
- A classifier that raises is now named in the log by its exception type. It used to be
  indistinguishable from a driver error no classifier recognizes.
- The docstring's rollback reassurance was wrong and is corrected: `get_connection`
  commits in its own `finally` on postgres and teradata alike, right after the rollback.
  Zero rows is what makes the probes harmless, not the rollback. The statement-level
  trigger case that leaves, and why grant introspection is not the mechanism, are both
  written down, with the postgres measurement for the first.
- `_USER_FAULT_TERADATA_CODES` now cross-references `_WRITE_DENIAL_TERADATA_CODES`, so a
  code added to one gets a decision about the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulkarayan

Copy link
Copy Markdown
Contributor Author

Rebased onto 5d7f3347 and pushed. Head is now 5788c258. Four commits on top of the
rebased original: 03b687b7, b598eb0a, 10bdd683, 5788c258.


Blocking

Snowflake dataclass fields deleted (tabossert snowflake.py:197, cubic snowflake.py:191)

FIXED, 03b687b7.

unstructured_ingest/processes/connectors/sql/snowflake.py:199-200: both fields are back,
declared after values_delimiter where they were. Your read of it was exactly right: the new
method landed on the two declarations, both readers stayed, and a fresh uploader raised
AttributeError on its first batch while the precheck, which never touches them, passed first.

The fixture at test_snowflake.py:42-43 no longer assigns them by hand, so all 30 tests in that
file now run against the fields' own defaults, and two tests cover the regression directly:
test_a_fresh_uploader_can_read_its_cached_properties constructs an uploader the way the
pipeline does and reads both properties, test_the_cached_fields_are_declared_on_the_dataclass
asserts both are on dataclasses.fields(). Deleting the two lines again turns 10 tests in that
file red, 8 of which the fixture used to mask.

test_ingest_unit red: per-connector drivers imported in the base test group (tabossert test_postgres.py:1, test_singlestore.py:2, test_snowflake.py:312)

FIXED, b598eb0a.

Singlestore and snowflake now forge the driver error rather than importing the driver, which is
the _FakeTeradataDriverError approach you pointed at. Both classifiers read one thing through
getattr and never the exception type. Singlestore reads errno, snowflake reads sqlstate
and errno, so the real classifier still runs in CI. The snowflake fake mirrors the driver's
unset defaults (errno=-1, sqlstate="n/a"), and each fake is paired with an importorskip
test asserting the real driver still fills those fields, so a fake cannot drift away from the
thing it stands in for.

Postgres is skipped, not forged: PostgresUploader.classify_write_denial is
@requires_dependencies(["psycopg2"]) and reads the SQLSTATEs out of psycopg2.errorcodes, so
there is no running it driverless. pytest.importorskip sits after the other imports to satisfy
E402.

Verified against a venv built the way CI builds it, uv sync --group test --locked, with all
four SQL drivers absent. Before: 2 collection errors and 7 failures, matching your read of run
34671765555. After: test/unit/connectors/sql is 272 passed, 3 skipped, and the full
test/unit suite is 1740 passed, 69 skipped.


cubic

sql.py:468, statement-level triggers fire on a zero-row probe

NOT CHANGED on postgres, where the premise does not hold; documented for teradata, which is the
one dialect it does hold on. sql.py docstring.

Measured on postgres 16.15 with AFTER INSERT and AFTER DELETE ... FOR EACH STATEMENT
triggers writing to an audit table, driving both probes through this connector's own connection
handling:

psycopg2.connect() default autocommit: False
transactional (what the connector does): audit rows left behind = 0
autocommit=True:                         audit rows left behind = 2

psycopg2 opens a transaction and this package never sets autocommit, so the rollback in
_run_write_probe discards whatever the trigger did. The autocommit row is the mechanism cubic
describes and it is real. It is just not the path postgres takes here.

SQLite has no statement triggers at all (FOR EACH STATEMENT is a syntax error) and only row
triggers, which zero rows cannot fire. That leaves Teradata, which has statement triggers and a
driver in autocommit: an AFTER ... FOR EACH STATEMENT trigger on the destination table there
would fire once per precheck and its side effect would stand. Not guarded, because the guard is
a transaction-semantics change on the one dialect this branch could not test against a live
server, and the PR already carries a waived environment leg for it. Written into the docstring
with the measurement so the next reader does not have to re-derive it.

The other suggestion, a non-DML privilege check, is answered under "grant introspection" below.

sql.py:434, the precheck pins the column snapshot in _columns

NOT CHANGED.

_columns is field(init=False, default=None) memoized in get_table_columns()
(sql.py:649), and it already spanned the whole job before this PR: upload_dataframe reaches
it twice per file, through can_delete() at sql.py:609 and _fit_to_schema() at sql.py:617.
A schema change mid-job was invisible then and is invisible now; warming the cache at precheck
moves where the window starts, from the first record to setup, and does not create it.

Invalidating after probing would not close it either, since the reload would pin a new snapshot
for the rest of the job, and it would let the probe's column list differ from the INSERT it stands
in for, which is the agreement test_write_probe_columns_match_the_uploaders_insert
(test_sql.py:516) exists to pin. Sharing the one call is deliberate.


Non-blocking

Rebase and the stale version bump

FIXED, 1741a0a7 (rebase) and 5788c258.

Rebased onto 5d7f3347. __version__.py and the CHANGELOG heading are both 1.11.16. Four files
conflicted: CHANGELOG, __version__.py, test_teradata.py (append against append, both blocks
kept), and teradata.py. Your point about the teradata conflict being semantic is why
_WRITE_DENIAL_TERADATA_CODES was re-derived rather than resolved line by line: it stays
{3523, 5315, 5612}, because all five codes #812 added (2621, 2665, 2666, 5407, 6706) are
value rejections, where the server accepted the statement and refused the value, not refusals
of a right.

Also useful to know: you are right that check-version would not have caught the stale bump.

A credential denied even SELECT still passes

FIXED, 10bdd683. sql.py, _classify_schema_read_denial.

Worth doing, and you are right that it is the gap between the fix and its title. A refused
schema read is now classified through the same one-sided contract as the two write probes:
refuse only on an answer the dialect calls an unambiguous denial, pass on everything else. The
SELECT * FROM <table> LIMIT 1 is not incidental. The write path runs it too, to conform the
frame and to name the columns of its own INSERT, so a credential refused there cannot upload.

Measured on postgres 16.15, four roles granted exactly one scenario each, plus the typo case:

noaccess   precheck -> REFUSED UserError(422): ...do not have SELECT permission on table 'elements'...
reader     precheck -> REFUSED UserError(422): ...INSERT... ...DELETE...
inserter   precheck -> REFUSED UserError(422): ...DELETE...
writer     precheck -> PASS
writer, table name misspelled -> PASS

Teradata is deliberately not closed, and routing the already-classified UserError through is
the thing I could not do. _USER_FAULT_TERADATA_CODES maps 3807 to "object does not exist or
user has no privilege on it", and a configured table that does not exist yet is a working
Teradata destination, because create_destination() builds it at upload time. Re-raising that
would refuse a destination that works today, which is the false refusal the whole check is
shaped to avoid. The narrower "re-raise only 3523/5315/5612" version needs the wrapped driver
error back out of __context__, and that seam is not worth adding for it. Written down in the
method docstring.

The three untouched connectors

NOT CHANGED.

Confirmed independently: databricks_delta_tables.py:145, vastdb.py:196 and
ibm_watsonx_s3.py:346 are the only three of the eight SQLUploader subclasses that override
precheck() without super(), and all three checks are read or metadata only. It is a scope
decision, and it should not outlive the memory of why. I have not filed anything, because
filing a ticket is pk's call.

Two dialects log a denial with no error code

FIXED, 10bdd683.

SQLite was the one-line fix you expected: sqlite_errorcode joins _SAFE_ERROR_ATTRS
(error.py). It is an int and a pure machine code, which is the allowlist's stated contract.
Measured against a real read-only database file with the exact INSERT probe, the summary goes
from OperationalError to OperationalError(sqlite_errorcode=8).

Teradata needed the seam you described, since attribute sniffing structurally cannot work there.
_probe_error_detail is a new base method returning safe_error_summary(error), which
TeradataUploader overrides to append teradata_error=NNNN from _extract_teradata_error_code.
Only the parsed code crosses, never the message it came from. Both probe log lines and the
schema-read line go through it.

A crash inside a classifier looks identical to a clean non-match

FIXED, 10bdd683. sql.py, _run_write_probe.

The classifier's own exception is now named by type in a debug line before the branch falls
through to inconclusive.

Connection count at precheck

NOT CHANGED, but the docstring was wrong and is corrected.

Your count is right: teradata goes to four connections at precheck, each with the auth handshake
and the SET SESSION CHARACTER SET UNICODE PASS THROUGH ON round-trip. Nothing in this round
adds a fifth: the schema read was already one of the four.

The related note is the more useful one and it was a real error in the docstring, which claimed
the rollback is what keeps the probes harmless. get_connection commits in its own finally on
postgres and teradata alike, immediately after the rollback. Zero rows is what makes them
harmless. Corrected.

Postgres 25006 during a failover

NOT CHANGED.

Right that a one-shot check has no retry, so a connection routed to a reader mid-failover would
get a hard 422. Keeping it: 25006 is the read-replica and default_transaction_read_only
endpoint, which is a destination that refuses every write for as long as it is configured that
way, and that is a case worth catching at setup. Dropping it to avoid a failover window trades a
permanent misconfiguration for a transient one, and a precheck failure during a failover is a
retryable job rather than a wrong answer. Unmeasured, as you say.

Does the 422 reach the user?

NOT CHANGED, and your read of this repo is correct.

pipeline.py:142 is the only non-test caller, and pipeline.py:148-153 catches every exception
into the failures dict and raises PipelineError("Initialization failed"). The message
survives as text; the 422 does not. Whether the platform calls precheck() directly is not
answerable from this repo, and changing the pipeline's error handling is a different change from
this one. The UserError over UserAuthError choice still pays off in the text either way,
which is the part the integration test covers.

Was grant introspection considered?

Considered and rejected; now written into the docstring, which is a fair hit that it was not.

It answers a different question: what the catalog records, not what the session is allowed to
do. They come apart exactly where this check matters: role composition and inheritance, a
pooler holding a session under a different role than the one that authenticated,
default_transaction_read_only and a standby endpoint, which no grant table shows, and
column-level grants, where the per-column answer has to be assembled and compared against the
uploader's column list by hand. It is also four dialect-specific catalog queries instead of one
statement each dialect already runs.

Keeping the two teradata code tables in sync

FIXED, 10bdd683. teradata.py, above _USER_FAULT_TERADATA_CODES.

Cross-reference added, framed as a decision rather than a pointer: the two tables answer
different questions, membership is not automatic in either direction, and a code added to one
gets a decision about the other.

Nit: the can_delete() except clause

NOT CHANGED.

You are right that it cannot fire today. can_delete() reaches only the memoized
get_table_columns(), which has already succeeded by then. It is there for the contract rather
than for a path: check_write_permissions promises to raise UserError and nothing else, and
can_delete() is a method subclasses override with independent I/O. IbmWatsonxUploader
already does (ibm_watsonx_s3.py:369, through its own get_table_columns() and a catalog
call), so the shape is in-tree, even though that connector does not reach this method.


cubic, local pass

One cubic review --base main over the rebased branch returns one P1, the same Teradata
statement-trigger item as the thread above, read off the docstring that now documents it. No
change: the measurement is in the docstring and the guard would be a transaction-semantics
change on the one dialect with no live server behind it.

Testing

  • uv sync --group test --locked venv, all four SQL drivers absent: test/unit is 1740 passed,
    69 skipped; test/unit/connectors/sql is 272 passed, 3 skipped, no collection errors.
  • Dev venv with the extras installed: make test-unit is 1818 passed.
  • ruff check . clean.
  • Live postgres 16.15: the four-role precheck matrix and the statement-trigger measurement above.

Snowflake and Teradata remain the waived environment leg, unchanged from the original body.

@paulkarayan
paulkarayan merged commit 0f857a2 into main Sep 21, 2026
43 checks passed
@paulkarayan
paulkarayan deleted the pk/sql-insert-permission-probe branch September 21, 2026 16:05
paulkarayan added a commit that referenced this pull request Sep 23, 2026
…#815)

## What & why

**Problem:** A non-admin Teradata user passes the destination connector
check even when it cannot create the table the job is about to create,
because the check never asks; the job asks instead, halfway through, and
is told the server is unreachable. That happens whenever the configured
table does not exist yet or no table is configured:
`create_destination()` builds it at upload time, which needs CREATE
TABLE, and on `main` the 3524 it gets back surfaces as "Failed to
connect to server". With the Database field blank it is worse, because
the table goes into whatever database the session defaults to, which can
be one the user never meant to write to, and nothing tells anyone which
database that was.

**Change:** The Teradata destination precheck now resolves the database
the write lands in (`SELECT DATABASE`, which is the configured one when
set, else the session default), logs it, and creates and drops a
throwaway `unstructured_precheck_<hex>` table in that database unless
the configured table already exists there. With no table configured the
probe always runs, since the platform names the table per workflow only
when it calls `create_destination()`. Teradata error 3524 refuses the
destination with a `UserError` that names the database; anything else
passes.

**Blast radius:** 3/5 -- the connector check now runs DDL on the
customer's Teradata; one connector, revert-safe.

This completes
#811, which
made the SQL precheck probe INSERT and DELETE on an existing table and
called out the missing-table case as the one gap it left.

## Linked ticket

none

Client-facing follow-up: a customer configured a Teradata destination
with a non-admin user and a blank Database field; it passed the
connector check and the run auto-created its table in a database the
user had not chosen.

## Impact

- **Customers:** Teradata destination users whose table is auto-created
now find out at connector check time that the user lacks CREATE TABLE,
with a message naming the database to grant it on, instead of a job that
fails with "Failed to connect to server". Destinations whose configured
table already exists see no change. A destination that works today keeps
passing: only 3524 refuses.
- **Internal:** support gets the resolved database in the uploader's
INFO log (`destination writes resolve to Teradata database '<db>'`),
which is the fact that was missing when triaging where an auto-created
table went. 3524 at upload time is now classified as the user's to fix
(422) rather than a connection failure.
- **Wire contract / clients:** a new `UserError` (422) from the Teradata
destination precheck: "The destination credentials can connect to the
database but do not have CREATE TABLE permission on database '<db>'.
...". `SQLUploader._write_denied_message` gained optional `object_kind`
/ `object_name` arguments; its default output is unchanged for every
other dialect. 3524 raised from `create_destination()` at upload time
changes from `DestinationConnectionError` to `UserError` "Teradata error
3524 (user does not have CREATE TABLE access to the database)".
- **Deployment target considerations:** same on SaaS, DI, in-VPC and
on-prem (including the Teradata OEM sites): the check talks only to the
customer's own Teradata. Not exercised against a real Teradata on any
target.

## Risk / rollback

- The precheck is slower on Teradata. It opens one more session and runs
up to four more statements (`SELECT DATABASE`, a `DBC.TablesV` lookup
when a table is configured, and the CREATE / DROP when that table is
absent or none is configured). The preflight controller treats a
precheck TIMEOUT as PASS, so on a slow logon this check can now time out
and pass where it used to finish. That is the fail-open direction: the
job still fails as it does today, it does not refuse a working
destination.
- The check writes DDL. The CREATE commits under the driver's
autocommit. If the DROP fails, a `unstructured_precheck_<hex>` table is
left in the destination database and a warning names it; the check still
passes. Every connector check on a destination with no table configured
creates and drops one probe table, including after the workflow's own
table exists.
- Only 3524 refuses. A user refused with some other code (5315, perm
space, a bad database name) passes the check and fails at upload as
before.
- With no table configured, a user whose per-workflow table already
exists but who has since lost CREATE TABLE is now refused, though the
job would have written to the existing table. Precheck cannot know the
per-workflow table name, so it asks for the right a first run needs.
- Revert the PR to back it out; nothing persists.

## How it was verified

- New unit tests cover: table absent and CREATE refused with 3524
(refused, database named, driver text absent); table absent and CREATE
allowed (probe created then dropped); table present (no CREATE or DROP;
the INSERT/DELETE probe still runs); blank Database (`SELECT DATABASE`
names the database in the probe); no table configured (the probe runs
without a lookup); the server's spelling of the database is what gets
quoted; other CREATE failures pass; a failed DROP passes and names the
leftover. All of them fail against `origin/main`'s module.
- The full unit suite passes (`pytest -n auto test/unit --ignore
test/unit/unstructured`), including the other SQL dialects that share
`_write_denied_message`.
- A fake-driver simulation drives the real `TeradataUploader` against a
modelled session default database and per-database grants; output under
Proof.
- NOT verified against a live Teradata. The 3524 code and the
`DBC.TablesV` lookup come from Teradata's documentation and the existing
`create_destination()`, not from a server.

## Proof

> **Proof waived (environment)** -- no live Teradata is reachable from
this machine. There are no `TERADATA_*` credentials locally (the live
integration tests in `test/integration/connectors/sql/test_teradata.py`
expect them exported by hand, and CI does not set them), and no Vantage
SQL listener answers here. Unblocked by a Teradata Vantage (a ClearScape
trial works) with an admin who can create a user lacking CREATE TABLE in
one database; then run the CREATE-probe red/green against `main` and
this branch.

**Repro (local, fake teradatasql driver, real `TeradataUploader` from
`origin/main`).** The "new precheck" rows are `main`'s precheck, which
already includes the INSERT/DELETE probe (host replaced with `<host>`):

```
B. blank Database, operator's default db IS admin_db, no rights there
  new precheck      connector check: PASS
                    job:              FAIL  DestinationConnectionError: Failed to connect to server <host>
                    table now in:     nowhere

E. Database explicitly set to admin_db, no rights there
  new precheck      connector check: PASS
                    job:              FAIL  DestinationConnectionError: Failed to connect to server <host>
                    table now in:     nowhere
```

**Failing tests against `origin/main`'s module:**

```
FAILED test_teradata_uploader_precheck_with_table_name_none
FAILED test_teradata_precheck_refuses_a_credential_that_cannot_create_the_missing_table
FAILED test_teradata_precheck_creates_and_drops_a_probe_table_when_the_table_is_missing
FAILED test_teradata_precheck_issues_no_create_when_the_table_exists
FAILED test_teradata_precheck_probes_the_session_database_when_database_is_blank
FAILED test_teradata_precheck_passes_when_the_create_probe_fails_for_another_reason[2644]
FAILED test_teradata_precheck_passes_when_the_create_probe_fails_for_another_reason[3803]
FAILED test_teradata_precheck_passes_when_the_create_probe_fails_for_another_reason[5315]
FAILED test_teradata_precheck_passes_when_the_create_probe_fails_for_another_reason[9999]
FAILED test_teradata_precheck_passes_and_names_the_leftover_when_the_drop_fails
FAILED test_teradata_precheck_quotes_the_database_as_the_server_spells_it
```

**After (same simulation, this branch).** Rows A, C and D do not move; B
and E now stop at the connector check (destination table name and host
replaced with `<table>` and `<host>`):

```
A. blank Database, operator's default db is its own, no rights in admin_db
  new precheck      connector check: PASS
                    job:              PASS
                    table now in:     ['operator_db.<table>']

B. blank Database, operator's default db IS admin_db, no rights there
  new precheck      connector check: FAIL  UserError: The destination credentials can connect to the database but do not have CREATE TABLE permission on database 'admin_db'. Records would fail to write. Grant CREATE TABLE on that database to the user this connector authenticates as.

C. blank Database, default db IS admin_db, rights there (direct or via a role)
  new precheck      connector check: PASS
                    job:              PASS
                    table now in:     ['admin_db.<table>']

D. blank Database, default db is own, table of that name ALREADY in admin_db
  new precheck      connector check: PASS
                    job:              PASS
                    table now in:     ['admin_db.<table>', 'operator_db.<table>']

E. Database explicitly set to admin_db, no rights there
  new precheck      connector check: FAIL  UserError: The destination credentials can connect to the database but do not have CREATE TABLE permission on database 'admin_db'. Records would fail to write. Grant CREATE TABLE on that database to the user this connector authenticates as.
```

Row C is the case to keep in mind: a user who holds CREATE TABLE in the
session's default database still passes and the table still lands there.
The check now logs which database that is; it does not stop it.

## Dependencies / merge order

none

--- 🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/Unstructured-IO/unstructured-ingest/pull/815?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: paulkarayan <pk@unstructured.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
paulkarayan pushed a commit that referenced this pull request Sep 24, 2026
Review follow-up on #815. The CREATE TABLE precheck probe asked the server
for less than create_destination() will: a one-INTEGER-column table against
a real DDL carrying CLOB, VECTOR32, JSON and a PRIMARY INDEX. A right one of
those types needs and CREATE TABLE does not carry was therefore invisible to
the check and hit the customer at upload instead. The probe now runs
create_destination()'s own statement under the throwaway name, through the
shared _elements_schema_sql(), so the rights it asks for are the rights the
upload needs: no wider, no narrower, which is the rule #811 set.

It runs UNQUALIFIED, the way create_destination() runs it, so it lands in the
same session database the check just resolved and no identifier is
interpolated into the SQL. That also answers cubic's quoting finding: a
database name with a double quote in it no longer reaches a statement.

3523 now refuses alongside 3524. The probe is the real statement, so any
answer that means "a right was refused and nothing else" is an answer about
the real CREATE, and a right the column types need comes back as 3523 rather
than 3524. Its message says which database and which code, and deliberately
does not tell the customer CREATE TABLE is what they are missing, because on
that code it is not. With the Database field blank both messages also say the
named database is only the session default and that the field exists: a DBA
who follows the message otherwise grants rights on a database nobody chose,
which is the complaint this check came from.

A refusal the server has already given now survives the way out of the block:
denied is set before the try, and a cursor close or a commit that raises after
the CREATE was refused no longer turns the refusal into "inconclusive".

Logging: the probe table is named BEFORE it is created, so a process killed
between the CREATE and the DROP leaves a traceable name; create_destination()
names the database it is creating in, which precheck alone used to report; and
each precheck outcome (skipped, refused, created, inconclusive) now has its own
line, with inconclusive downgraded to info to match _run_write_probe.

Tests cover the real-DDL statement, the 3523 refusal, the blank-Database
sentence, the probe name in the log, a non-driver CREATE error, SELECT DATABASE
raising and returning nothing, the DBC.TablesV lookup raising, get_cursor()
raising, and teardown raising after a confirmed refusal. The live integration
test now asserts the precheck left no unstructured_precheck_% table behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
ci — 5788c258 Deployed Sep 21, 2026 by paulkarayan via test_install_cli (3.11) #4230
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prio:need Blocking / committed -- a customer or release depends on it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants