fix(none): refuse a Teradata destination that cannot create its table - #815
Conversation
A Teradata destination whose table does not exist yet, or that names no table, passed the connector check even when the user could not create that table. The job then failed at create_destination(), and the 3524 it hit surfaced as "Failed to connect to server". With the Database field blank, the table goes into whatever database the session defaults to, and nothing said which one. The zero-row INSERT/DELETE probe from #811 does not reach this case: it needs the table to exist. Precheck now resolves the database the write lands in with SELECT DATABASE (the configured one when set, else the session default, as the server spells it) and logs it. It then creates and drops a throwaway unstructured_precheck_<hex> table qualified with that database, unless the configured table already exists there (the DBC.TablesV lookup create_destination() now shares). With no table configured the probe always runs: the platform names the table per workflow when it calls create_destination(), so precheck has nothing to look up. Only 3524 refuses, as a UserError naming the database through the shared write-denied message; any other failure warns and passes, and a failed DROP names the leftover table in a warning. An existing configured table is left to #811's probe. 3524 joins _USER_FAULT_TERADATA_CODES and _WRITE_DENIAL_TERADATA_CODES: it is a privilege refusal and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="unstructured_ingest/processes/connectors/sql/teradata.py">
<violation number="1" location="unstructured_ingest/processes/connectors/sql/teradata.py:653">
P2: Escape double quotes in the resolved database identifier before building the qualified probe SQL. As written, databases containing a quote make the probe fail and the outer check treats that failure as a pass, so the destination can still fail later during table creation.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
|
|
||
| def _probe_table_creation(self, cursor: "TeradataCursor", *, database: str) -> bool: | ||
| """CREATE then DROP a throwaway table in ``database``. True iff CREATE got 3524.""" | ||
| qualified = f'"{database}"."{_PRECHECK_PROBE_TABLE_PREFIX}{uuid4().hex[:16]}"' |
There was a problem hiding this comment.
P2: Escape double quotes in the resolved database identifier before building the qualified probe SQL. As written, databases containing a quote make the probe fail and the outer check treats that failure as a pass, so the destination can still fail later during table creation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured_ingest/processes/connectors/sql/teradata.py, line 653:
<comment>Escape double quotes in the resolved database identifier before building the qualified probe SQL. As written, databases containing a quote make the probe fail and the outer check treats that failure as a pass, so the destination can still fail later during table creation.</comment>
<file context>
@@ -593,11 +595,87 @@ def precheck(self) -> None:
+
+ def _probe_table_creation(self, cursor: "TeradataCursor", *, database: str) -> bool:
+ """CREATE then DROP a throwaway table in ``database``. True iff CREATE got 3524."""
+ qualified = f'"{database}"."{_PRECHECK_PROBE_TABLE_PREFIX}{uuid4().hex[:16]}"'
+ try:
+ cursor.execute(f"CREATE TABLE {qualified} (probe_col INTEGER)")
</file context>
| qualified = f'"{database}"."{_PRECHECK_PROBE_TABLE_PREFIX}{uuid4().hex[:16]}"' | |
| escaped_database = database.replace('"', '""') | |
| qualified = f'"{escaped_database}"."{_PRECHECK_PROBE_TABLE_PREFIX}{uuid4().hex[:16]}"' |
|
Approving. Nothing here blocks. Everything except a 3524 fails open, unit CI is green (the SQL integration jobs were skipped, so no Teradata code ran beyond mocks), and the body is upfront that none of this has hit a live Teradata. Notes below, most important first; 1 and 2 are the ones I'd look at before merge. 1. A no-table destination that works today can now be refused, and the Impact line says it can't. Impact says "A destination that works today keeps passing: only 3524 refuses." Risk says a no-table user whose per-workflow table exists but who has since lost CREATE TABLE is now refused. Risk is the accurate one: 2. The probe asks for less than the real CREATE, and only 3524 can refuse it. The probe is 3. With Database blank, the refusal points at a fix the customer probably doesn't want. In row B the message says to grant CREATE TABLE on 4. Question on row C. The client follow-up is a run that succeeded and put its table in a database nobody picked. That's row C, which per the body still passes and still lands there; the new signal is the INFO line in precheck ( 5. The outer 6. Probe tables leaked by a killed process can't be traced. A failed DROP, including one on a dropped connection, logs the table name ( 7. Test gaps.
8. Version vs #816. This is 1.11.18 and #816 is 1.11.17, both off 1.11.16. Both change line 1 of 9. The body's wire-contract text doesn't match the code. The body quotes upload-path 3524 as "Teradata error 3524 (user does not have CREATE TABLE access to the database)". The descriptor at 10. 3524 is reclassified on the source side too. 11. Nits.
Q. Was cubic's quoting note on |
tabossert
left a comment
There was a problem hiding this comment.
Approving. Notes are in my comment above; none of them block.
|
Thanks for this. Answering all of it, but the ordering matters: this PR merged before the follow-up work landed, so nothing below is a commit on this branch. It is on 1. Fixed, and you were right about which line was lying. The Impact line is corrected in the body. The Risk section was the accurate one: with no table configured, Your gating question, answered from the orchestration code rather than guessed. Observed, three separate triggers:
So yes, per job, wherever preflight is enabled. A workflow does break the day a DBA tightens grants. I kept the refusal rather than downgrading to a warning, and I think that is the right call, but it is your trade-off and I want to be explicit about it. Warning when 2. Fixed, and your unqualified suggestion is what it does. The probe now runs 3. Fixed. Both refusal messages now carry a hint when 4. Answered, and the cheap improvement is in. 5. Fixed. 6. Partly fixed, sweep declined. The traceability gap is closed: the probe table is named in a log line before the CREATE runs, so a process killed between CREATE and DROP leaves a record of the name. I did not add a sweep. Your own warning is the reason: bounded by 7. Fixed. All the paths you listed have tests now: 8. Confirmed, and it has already happened. 9. Fixed in the body. The real message is 10. Fixed in the CHANGELOG and the body. 11.
Q. Yes, and it is documented, but I did not swap to it. Teradata's SQL Data Manipulation Language manual (B035-1146, 17.00, EXPLAIN Request Modifier, "Required Privileges") says: "To EXPLAIN a request, you must have the permissions that are required to execute that request." The mechanism holds up: SQL Request and Transaction Processing (B035-1142) puts Security between the Resolver and Query Rewrite, upstream of the Optimizer that produces the EXPLAIN output, so an EXPLAIN cannot reach the plan without passing the rights check. DDL is explainable and the manual carries a worked Three reasons I left it as a candidate rather than doing it. The error code EXPLAIN returns on a refusal is inferred, not observed: nothing I found documents the number, and building the refusal on a code we have not seen come back from an EXPLAIN is the same mistake as the one-column probe. In the worked plan the "table already exists" check is a runtime Nothing here has touched a live Teradata. That has not changed. |
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>
Review follow-up on #815, which merged before this landed. With no table configured the CREATE TABLE probe always runs, because the caller names the table only when it calls create_destination() and precheck has nothing to look up. That is the one case the check can refuse a credential the job would not have needed: a user whose per-workflow table already exists and who has since lost CREATE TABLE is refused here, even though create_destination() would have found the table and returned early without creating one. The refusal is kept rather than downgraded to a warning. Warning when the table name is unset would switch the check off on the path the platform uses, which is the path #815 targets. Instead the message now says why it probed and points at the Table Name field, which makes check_create_table_permission look the table up and skip the probe entirely. A dead end becomes something the customer can act on. CHANGELOG moves to its own 1.11.19 section: #815 shipped as 1.11.18, so the released entry is restored verbatim and this version documents the delta, plus the two behaviour changes 1.11.18's entry did not record (a no-table destination can now be refused where it used to pass, and 3524 was reclassified on the source side as well as the destination). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… DDL (#818) ## What & why **Problem:** A Teradata destination user who cannot create the table the job auto-creates still finds that out at upload time rather than at the connector check, because the check asks a narrower question than the upload does. The probe that shipped in 1.11.18 creates a one-INTEGER-column table, while `create_destination()` runs the connector's real DDL with `CLOB`, `VECTOR32`, `JSON` and a `PRIMARY INDEX`. A right one of those column types needs and CREATE TABLE alone does not carry is invisible to the check, so the credential passes and the job fails partway through. Separately, a user whose per-workflow table already exists and who has since lost CREATE TABLE is now refused by that check with no way to act on the message, even though the upload would have found the table and never created one. **Change:** The probe runs `create_destination()`'s own statement under the throwaway name, unqualified, so the rights it asks for are the rights the upload needs. Teradata 3523 refuses alongside 3524, with a message that does not claim CREATE TABLE is the missing right. Blank Database and unset Table Name each add a sentence naming the field that settles the refusal, so the customer has something to do. A refusal the server already gave survives a failure tearing the session down, and each precheck outcome gets its own log line. **Blast radius:** 3/5 -- the connector check runs the real destination DDL on the customer's Teradata; one connector, revert-safe. ## Linked ticket none Follow-up to #815, which merged before this work landed. This branch closes Trevor's review items **2, 3, 4, 5, 6 (traceability), 7, 10, 11a and 11b** from #815 (comment), and the code half of item **1**. Items **9** and the body half of **1** were fixed by editing the merged body of #815. Item **8** (version collision) and item **11c** (`TableKind = 'T'`) are answered in that thread and not changed here; the reasoning is in #815 (comment). **Provenance, so the diff is not mis-read.** The first commit here, `7e6f164a` "probe Teradata CREATE with the real destination DDL", was written in a separate earlier session against the branch of #815 and was still unpushed when that PR merged. It is not new work and it is not mine; this branch carries it forward unchanged, cherry-picked onto `main` (the trees matched exactly, so it applied clean). The second commit is the new work: the Table Name hint, its tests, and the CHANGELOG restructure. 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 are now refused for the rights the real CREATE needs, not just CREATE TABLE on the database: a `VECTOR32` column refused UDTUSAGE on `SYSUDTLIB` comes back as 3523 and is caught here instead of failing the job. Refusal messages become actionable rather than dead ends. With the Database field blank the message says the database it names is only the session default, so a DBA does not grant rights on a database nobody chose. With no Table Name configured the message says the check had to create a table to test the right, and that setting Table Name makes the check look the table up instead. That is the escape hatch for the one case this check can refuse a credential the job would not have needed. Destinations with a configured, existing table are unchanged: they still get the 1.11.16 INSERT/DELETE probe. - **Internal (devs / ops / other teams):** support gets one INFO line per precheck outcome (skipped, refused, created, inconclusive) instead of a single line that could not distinguish "probe passed" from "skipped, table exists", and `create_destination()` now names the database on the line that actually creates the table. The probe table is named in a log line *before* the CREATE runs, so a pod killed between CREATE and DROP leaves a traceable name. No other connector is touched; `_write_denied_message` is called with the same arguments 1.11.18 introduced. - **Wire contract / clients:** the precheck `UserError` (422) messages gain trailing sentences (the blank-Database hint from 1.11.18, plus the new Table Name hint). Teradata **3523 is a new refusal code** for the destination precheck: a credential that previously passed the CREATE probe and failed at upload now gets a 422 at check time. `test_teradata.py:1329` pins the upload-path wording and is unchanged. Nothing outside the `teradata` connector changes; the shared `_USER_FAULT_TERADATA_CODES` map is not edited by this branch. - **Deployment target considerations:** the same on every target, because this is library code in the connector rather than anything deployed on its own. It runs wherever a Teradata destination precheck runs, which is per job where the preflight gate is enabled and on every UI test-connection. Not verified on any target with a real Teradata (see Proof). ## Risk / rollback - The probe now runs the real DDL, which is a larger statement than the one-column stand-in. It still creates and drops one table and commits under the driver's autocommit, so the leak window is unchanged: two statements. A failed DROP still passes the check and names the leftover. - 3523 refusing is the one genuinely wider behaviour here. It is bounded to the CREATE probe's own `except`, and 3523 means a refused right and nothing else, so it cannot turn a missing table into a permissions error. - No sweep of leftover probe tables was added, deliberately: bounded wrong it can drop a concurrent precheck's in-flight probe under the same credential. - Revert the PR to back it out; nothing persists, no migration. ## How it was verified - `make test-unit`: 1843 passed. The teradata module alone is 168, up from 166, the two new ones being the Table Name hint firing when the field is unset and staying off when it is set. - `make check` (`ruff check .`): all checks passed. `ruff format` was deliberately **not** run: the file was already format-dirty at `HEAD` before any edit here (verified with `git show HEAD:<file> | ruff format --check`), 17 files repo-wide fail `ruff format --check`, and CI's lint job runs `ruff check` only. Reformatting would have been unrelated churn on a review diff. - The integration test asserts the precheck leaves no `unstructured_precheck_%` table behind, with `_` escaped in the LIKE so the prefix cannot match by accident. It is gated on `TERADATA_*` credentials and skipped without them, so it did not run here. - NOT verified against a live Teradata. The 3523 classification comes from Teradata's Database Messages manual and the existing `_USER_FAULT_TERADATA_CODES`, not from a server. ## Proof > **Proof waived (environment)** -- no live Teradata is reachable from this machine. There are no `TERADATA_*` credentials locally 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, and a second lacking UDTUSAGE on `SYSUDTLIB`; then run the CREATE-probe red/green for both 3524 and 3523 against `main` and this branch, and land the restricted-user integration test modelled on `test_postgres_destination_precheck_refuses_a_credential_that_cannot_write` (`test_postgres.py:247`). That test is owed and is not in this branch. Unit-level repro-first record for the new behaviour in this branch (the Table Name hint): **Red** -- test written before the fix, against this branch's first commit: ``` $ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py -k table_name_field E assert 'No Table Name is configured' in "The destination credentials can connect to the database but do not have CREATE TABLE permission on database 'test_db'. Records would fail to write. Grant CREATE TABLE on that database to the user this connector authenticates as." FAILED test_teradata_precheck_refusal_points_at_the_table_name_field_when_it_is_unset 1 failed, 167 deselected in 0.41s ``` **Green** -- after adding `_unset_table_hint()` and appending it to both refusal messages: ``` $ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py 168 passed in 0.64s $ make test-unit 1843 passed, 1 warning in 37.62s $ make check uv run --locked --no-sync ruff check . All checks passed! ``` The second test in that pair (`..._omits_the_table_name_hint_when_one_is_configured`) passes both before and after by construction; it is there to pin that the hint stays off for a customer who already set the field. ## Dependencies / merge order none Note for whoever merges: this takes **1.11.20**, renumbered from 1.11.19 when #814 shipped that version on main. #816 is still at 1.11.17 while `main` is at 1.11.19, and is currently conflicting. `scripts/version-sync.sh:125` only rejects a version *equal* to main's, so a resolution there that keeps 1.11.17 goes backwards and CI will not catch it. Whichever of these two lands second needs its version re-checked by hand, not just its conflict resolved. --- <sub>Generated from Orca worktree `pk-teradata-precheck-followup` (branch `pk/teradata-precheck-review-followup`).</sub> 🤖 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/818?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>
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 onmainthe 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 throwawayunstructured_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 callscreate_destination(). Teradata error 3524 refuses the destination with aUserErrorthat 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
sql.py:380-382) on that one path.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.UserError(422) from the Teradata destination precheck: "The destination credentials can connect to the database but do not have CREATE TABLE permission on database ''. ...".SQLUploader._write_denied_messagegained optionalobject_kind/object_namearguments; its default output is unchanged for every other dialect. 3524 raised fromcreate_destination()at upload time changes fromDestinationConnectionErrortoUserError"Teradata error 3524 (user does not have the required access to the database) for ''." (the descriptor is the one in_USER_FAULT_TERADATA_CODES, with the target appended by_raise_classified_teradata_error;test_teradata.py:1329pins that wording). The same map is shared by the source side, soTeradataIndexer.precheck'sSELECT TOP 1probe now raisesUserErrorrather thanSourceConnectionErroron 3524 as well.Risk / rollback
SELECT DATABASE, aDBC.TablesVlookup 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.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.How it was verified
SELECT DATABASEnames 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 againstorigin/main's module.pytest -n auto test/unit --ignore test/unit/unstructured), including the other SQL dialects that share_write_denied_message.TeradataUploaderagainst a modelled session default database and per-database grants; output under Proof.DBC.TablesVlookup come from Teradata's documentation and the existingcreate_destination(), not from a server.Proof
Repro (local, fake teradatasql driver, real
TeradataUploaderfromorigin/main). The "new precheck" rows aremain's precheck, which already includes the INSERT/DELETE probe (host replaced with<host>):Failing tests against
origin/main's module: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>):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