fix(teradata): refuse a table the credential cannot read - Pylon 3035 - #820
paulkarayan wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
2 issues 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="test/unit/connectors/sql/test_teradata.py">
<violation number="1" location="test/unit/connectors/sql/test_teradata.py:2764">
P2: These tests do not pass as written: after the inconclusive existing-table probe, `precheck()` retries the same failing `SELECT TOP 1 *` through `check_write_permissions()`, so it raises instead of reaching the “must not raise” assertions. Make the failure one-shot if this test only covers the first probe, or update the precheck flow to reuse the inconclusive result before invoking the write checks.</violation>
</file>
<file name="unstructured_ingest/processes/connectors/sql/teradata.py">
<violation number="1" location="unstructured_ingest/processes/connectors/sql/teradata.py:857">
P2: The existing-table read classifier reports database-level denials 3524 and 5315 as `SELECT` permission missing on the table. Tell the customer to grant the required access on `database` for these codes, otherwise the actionable precheck message sends the DBA to the wrong object.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| _scripted_cursor( | ||
| mock_cursor, | ||
| exists=True, | ||
| fail={_READ_PROBE: _FakeTeradataDriverError(f"[Teradata Database] [Error {code}] x")}, |
There was a problem hiding this comment.
P2: These tests do not pass as written: after the inconclusive existing-table probe, precheck() retries the same failing SELECT TOP 1 * through check_write_permissions(), so it raises instead of reaching the “must not raise” assertions. Make the failure one-shot if this test only covers the first probe, or update the precheck flow to reuse the inconclusive result before invoking the write checks.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/unit/connectors/sql/test_teradata.py, line 2764:
<comment>These tests do not pass as written: after the inconclusive existing-table probe, `precheck()` retries the same failing `SELECT TOP 1 *` through `check_write_permissions()`, so it raises instead of reaching the “must not raise” assertions. Make the failure one-shot if this test only covers the first probe, or update the precheck flow to reuse the inconclusive result before invoking the write checks.</comment>
<file context>
@@ -2602,3 +2604,306 @@ def test_teradata_precheck_refusal_omits_the_table_name_hint_when_one_is_configu
+ _scripted_cursor(
+ mock_cursor,
+ exists=True,
+ fail={_READ_PROBE: _FakeTeradataDriverError(f"[Teradata Database] [Error {code}] x")},
+ )
+
</file context>
| if code in _WRITE_DENIAL_TERADATA_CODES: | ||
| return self._write_denied_message("SELECT") + self._blank_database_hint() |
There was a problem hiding this comment.
P2: The existing-table read classifier reports database-level denials 3524 and 5315 as SELECT permission missing on the table. Tell the customer to grant the required access on database for these codes, otherwise the actionable precheck message sends the DBA to the wrong object.
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 857:
<comment>The existing-table read classifier reports database-level denials 3524 and 5315 as `SELECT` permission missing on the table. Tell the customer to grant the required access on `database` for these codes, otherwise the actionable precheck message sends the DBA to the wrong object.</comment>
<file context>
@@ -749,6 +784,89 @@ def _probe_table_creation(
+ if not _is_teradata_driver_error(error):
+ return None
+ code = _extract_teradata_error_code(error)
+ if code in _WRITE_DENIAL_TERADATA_CODES:
+ return self._write_denied_message("SELECT") + self._blank_database_hint()
+ if code == _OBJECT_DOES_NOT_EXIST_CODE:
</file context>
| if code in _WRITE_DENIAL_TERADATA_CODES: | |
| return self._write_denied_message("SELECT") + self._blank_database_hint() | |
| if code in {3523, 5612}: | |
| return self._write_denied_message("SELECT") + self._blank_database_hint() | |
| if code in {3524, 5315}: | |
| return self._write_denied_message( | |
| "SELECT", object_kind="database", object_name=database | |
| ) + self._blank_database_hint() |
An admin creates the destination table; the connector runs as a non-admin user with no rights on it. The destination check reported Successful and then every record of the job failed. When the configured table is already in the data dictionary, create_destination() builds nothing and the job's first act on that table is to read it. The precheck used to return at that point without probing anything. The shared write check could not catch it either: this connector's get_table_columns() re-raises the driver error as a typed UserError, and sql.py classifies what it is handed by asking whether the exception came from the driver, which the wrapper is not, so every schema-read denial on this dialect was swallowed. The existing-table branch now runs that read and refuses a credential the server turns away. Measured on Vantage 20.0.0.68: reading a table you hold nothing on answers 3523, "The user does not have SELECT access to <database>.<table>", named every time, qualified or not, on all 435 tables the dictionary lists there that the credential cannot reach. 3523 is already an unambiguous refusal in this module, so what fixes the customer's case is that the probe runs at all. 3807 refuses there too, and only there. That is a belt for a server that answers "not found" where the one we measured answers 3523: DBC.TablesV is the non-restricted form of the view and returns every object regardless of ownership or privilege -- 795 rows on that server against 360 the credential could reach -- so a row in it rules out the table being absent. With no row nothing changed: 3807 still means the table is not there yet, still passes, and the CREATE TABLE probe still owns that case. This also corrects three comments that asserted Teradata hides an existing object behind 3807, and the 3807 descriptor that carried the same claim into the customer-facing message. It does not. The code means the object was not found; the denial for an object that is there is 3523. The dictionary lookup now covers TableKind 'O' as well as 'T'. Teradata files a table with no primary index and no partitioning under 'O', so a destination an admin created that way read as absent, and the case above went straight back to the CREATE probe and passed. Kinds that are not base tables stay out: calling a view or a queue table "the destination already exists" would hand the upload an object it cannot write instead of the 3803 it raises today. The probe runs get_table_columns()'s own statement and hands over the column list it reads, so a destination that works costs no extra round trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9bffcb5 to
dceb4eb
Compare
There was a problem hiding this comment.
0 issues found across 3 files (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 2 unresolved issues from previous reviews.
Re-trigger cubic
|
Approving. The fix is right for the reported case: the probe runs the same read the upload needs first, so it can't refuse a destination that works today, and an INSERT-only credential was already failing every job. Everything below is non-blocking, roughly in priority order. 1. A transient dictionary-lookup failure still lets the swallow through. If Root-cause fix: override 2. 5612 isn't a privilege code. Teradata's message catalog has 5612 as "A user, database, role, or zone with the specified name already exists" (https://docs.teradata.com/r/Teradata-VantageCloud-Lake-Analytics-Database-Messages/Database-Messages/5612). The module labels it "user does not have any access to the object" ( 3. Several comments contradict what the PR now says about 3807.
4. An inconclusive probe reads the table twice. On a code that isn't a denial (2644, 3807, ...) or a non-driver error, the probe logs "inconclusive" and returns, then 5. 6. Test gaps in the new branch. On the existing-table read path only 3523 goes through Smaller stuff
Questions
|
tabossert
left a comment
There was a problem hiding this comment.
Approving; non-blocking notes in the comment above.
What & why
Problem: A customer points a Teradata destination at a table their admin created, and every record silently fails to write, because the non-admin credential the connector authenticates as holds nothing on that table. They find out after a job has run, not before, because the connector check told them it was Successful. The check proved only that the credential could log on. When the data dictionary already listed the configured table, the precheck returned early without ever reading it, and the shared write probe that would have caught it never ran: Teradata's
get_table_columns()re-raises the driver error as aUserError, and the classifier that decides whether a schema-read failure is a denial tests the exception's module. A wrapped error is not a driver error, so every denial on this dialect was swallowed, including the unambiguous ones.Change: When the dictionary says the configured table exists, the precheck now runs the same
SELECT TOP 1 *the upload runs first, and refuses a credential the server turns away on the codes that mean "no privilege" and nothing else. A table that is not there yet still passes, becausecreate_destination()builds it.Blast radius: 3/5 -- one connector, but it turns a passing check into a hard refusal, so a wrong classification would block a destination that works.
Linked ticket
none
Client-facing follow-up: connector check reported Successful for a destination the credential could not write, and every record then failed -- Pylon 3035
Impact
Customers: anyone configuring a Teradata destination against a table they do not own now learns it at connector-creation time instead of after a job has run and failed. The message names the table and the grants to ask for. Nobody whose credential can write its destination sees any change: a readable table passes, and a table that does not exist yet passes.
Internal: support stops fielding "the check passed but the job failed" on this connector. No other dialect moves --
sql.pygains only a corrected comment, and the refusal lives entirely in the Teradata uploader.Wire contract / clients: a new
UserError(422) at precheck for a case that used to return success. It is a user-fault, non-retryable classification, which is what the platform already does with every other precheck refusal in this module.One behaviour change beyond the check itself, called out because it is easy to miss: the dictionary lookup now matches
TableKind IN ('T','O')rather than'T'alone.'O'is a table with no primary index and no partitioning, so an admin-created NoPI table used to read as absent._table_existsis shared withcreate_destination(), which means such a table previously got aCREATEand a 3803 "already exists", and now gets written into, the same as any'T'table. That is the behaviour the connector always intended for an existing destination, but it is a change.Deployment target considerations: ships wherever the uploader plugin ships. No target-specific behaviour: the change is one SQL statement and a classification, with no new dependency, config or egress.
Risk / rollback
The refusal is one-sided by construction: it fires only when the dictionary returns a row for the configured table AND the server refuses this credential's read of it. Anything unrecognised is logged and passes, so the failure mode is the old behaviour, not a false refusal. Revert the PR to back it out; there is no migration and no state.
How it was verified
Against a live Teradata Vantage 20.0.0.68, reached through the teradata-dev DI, and a before/after on the same server with the same configuration:
precheck(), with the database set toPDCRDATAand the table toPDCR_Table_Retention(a table the dictionary lists and the credential cannot read), raisedUserError422: "The destination credentials can connect to the database but do not have SELECT permission on table 'PDCR_Table_Retention'. Records would fail to write. Grant SELECT on that table to the user this connector authenticates as." No driver text, host, user or password in the message.TD_OFSDB.PURGED_OBJECTSbehaved identically.write-permission check skipped, table schema unavailable: UserError(status_code=422). That is the reported defect, reproduced live.data_scientist.pp99_table, which that credential can read, passed.make check-ruffis clean.Not verified: the absent-table path was deliberately not driven against that server, because
precheck()is not read-only there -- it creates and drops a real table under a throwaway name and then runs zero-row INSERT and DELETE probes, and the server is Teradata's preprod rather than our sandbox. That path is covered by unit tests.Proof
Repro-first, with one leg WAIVED.
Repro. A fake-driver simulation drives the real connector code with the customer's configuration: Database field blank, the session's default database is the admin's, the destination table already exists there, and the connector's user holds
CREATE TABLE/DROP TABLEon the database and nothing on the table. Plus a unit test that scripts the same answers throughprecheck()and, deliberately, does not mockget_table_columns()-- mocking it is what hid this defect through two releases.Red. With the new tests present and the connector reverted to
origin/main:A second red, for the false refusal the independent review found, with the connector at the revision that still refused on 3807 and the two new guards present:
A third red, for a hole found after the first commit, against that commit:
Fix. The existing-table branch of
check_create_table_permissionno longer just returns. It runsget_table_columns()'s own statement and classifies the driver's raw answer: the codes that mean "no privilege" and nothing else refuse, as they do everywhere else in this module. Nothing refuses on 3807.Why the dictionary row settles it.
DBC.TablesVis the non-restricted form of the view. Teradata's Data Dictionary manual: the non-X views "return every row of every column defined on the underlying table", whereviewnameXreturns only what the requesting user owns, created, was granted, or reaches through a current or nested role. Measured on the live server: 795 tables listed to a credential that can reach 360 of them. So a row inTablesVis an existence fact that does not depend on this credential's rights.What the server actually answers, which is not what this module assumed. Reading any of the 435 tables that are in
TablesVand not inTablesVXreturns:Every time, on six tables across six databases, DBC and user databases alike, and identically for the qualified form and the unqualified form this connector issues. Not one 3807. Teradata does not hide existing objects behind a non-existence code; it names the database and the table. Three comments in this module asserted the opposite and are corrected here. 3523 was already an unambiguous denial, so what fixes the customer's case is simply that the probe now runs. One unit test pins the verbatim measured 3523 including its
[SQLState 42000]segment, which no hand-written mock in the file carried: a regex tightened to expect the message right after[Error NNNN]would pass every other test and drop the refusal on a real server.Why 3807 refuses nothing, which an independent review changed. An earlier revision of this branch refused on 3807 whenever
DBC.TablesVhad just returned a row, on the reasoning that the dictionary settles the "object does not exist" half of that code. An independent GPT review found the hole: an admin can drop the table between the dictionary lookup and the read, and the upload that follows would find it absent, havecreate_destination()build it, and write successfully. Refusing there turns a race into a working destination reported as broken, which is the one outcome this check may not produce. 3807 now refuses at no site, and two tests pin that.After. The live before/after and the unit results are under "How it was verified".
Dependencies / merge order
none