dataplat 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dataplat-0.2.1 → dataplat-0.2.2}/CHANGELOG.md +61 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/PKG-INFO +1 -1
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/dbt_orphans.py +63 -15
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role.py +19 -2
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/describe.py +54 -5
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/long_queries.py +29 -2
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/orphans.py +75 -6
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role.py +64 -5
- {dataplat-0.2.1 → dataplat-0.2.2}/pyproject.toml +1 -1
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role.py +5 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_describe_pg.py +84 -27
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_long_queries_pg.py +219 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_orphans_pg.py +146 -16
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_roles_pg.py +51 -24
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_describe.py +29 -1
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_long_queries.py +94 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_orphans.py +49 -3
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role.py +72 -18
- {dataplat-0.2.1 → dataplat-0.2.2}/uv.lock +1 -1
- {dataplat-0.2.1 → dataplat-0.2.2}/.github/workflows/ci.yml +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/.github/workflows/release.yml +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/.gitignore +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/.python-version +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/LICENSE +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/README.md +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_lazy.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_missing.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_options.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_prompt.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_render.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/superset.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/runner.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/_common.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/rds.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/redshift.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/secrets.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/config.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/_common.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/_report.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/describe.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/long_queries.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_create.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_drop.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_list.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/top_tables.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_common.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_cursor.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_resource.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/connections.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/definitions.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/destinations.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/enums.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/jobs.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/sources.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/tags.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/templates.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/tui.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/workspaces.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/app.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/open.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/status.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/deps.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/envrc.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/errors.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/registry.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/main.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/_resource.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/client.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/connections.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/definitions.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/destinations.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/jobs.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/sources.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/tags.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/workspaces.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/aws/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/aws/auth.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/_like.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/connection.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role_admin.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role_dialects.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/targets.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/top_tables.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/superset/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/superset/client.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_commands.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_cursor_logic.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_guards.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_tui.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_aws_secrets.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_aws_secrets_write.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_cli_smoke.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_config.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_common.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_long_queries.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_query.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_dbt_orphans.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_describe.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_github_runner.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_missing_deps.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_open.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_prompt.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_rds.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_redshift.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_regression.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_render.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role_create.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role_drop.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_status.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_superset.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_top_tables.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/conftest.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_deps.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_envrc.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_registry.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/conftest.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_harness.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_top_tables_pg.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_client.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_connections.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_definitions.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_destinations.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_jobs.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_sources.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_workspaces.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/aws/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/aws/test_auth.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_connection.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role_admin.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role_dialects.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_targets.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_top_tables.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/superset/__init__.py +0 -0
- {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/superset/test_client.py +0 -0
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
Closes the six defects 0.2.1's integration suite found and pinned as expected
|
|
6
|
+
failures. No expected failures remain.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- `dp db role show` claimed `Password set: yes` for every role, including a
|
|
11
|
+
passwordless `NOLOGIN` group. The attribute query read `rolpassword` from
|
|
12
|
+
`pg_roles`, whose view definition returns the literal `'********'` and can
|
|
13
|
+
never be NULL. Only `pg_authid` holds the real verifier and it is
|
|
14
|
+
superuser-only, so the field is now tri-state and reports `unknown` — with a
|
|
15
|
+
hint saying why — when the connecting role cannot read it. The privilege is
|
|
16
|
+
probed before the query rather than discovered by catching an error, since a
|
|
17
|
+
permission failure would abort the surrounding transaction.
|
|
18
|
+
|
|
19
|
+
- `dp db describe <schema>` never reported `USAGE` grants. The query read
|
|
20
|
+
`information_schema.usage_privileges`, which on PostgreSQL does not cover
|
|
21
|
+
schemas at all, so the USAGE half of the union always returned nothing. The
|
|
22
|
+
PostgreSQL path now reads the schema ACL directly.
|
|
23
|
+
|
|
24
|
+
- `dp db role show` under-counted a role's tables when it owned a partitioned
|
|
25
|
+
table. Both `relkind` `'r'` and `'p'` map to the label "table" and the
|
|
26
|
+
aggregation assigned rather than accumulated, so one group overwrote the
|
|
27
|
+
other while the total was summed separately and stayed right — the per-schema
|
|
28
|
+
breakdown contradicted its own total.
|
|
29
|
+
|
|
30
|
+
- `dp db dbt-orphans purge` aborted the whole batch when a single relation had
|
|
31
|
+
vanished between scan and purge. The generated statement now uses
|
|
32
|
+
`IF EXISTS`, matching `top-tables`, so a missing relation is a no-op. When a
|
|
33
|
+
dependent object genuinely blocks a drop the purge still stops — that
|
|
34
|
+
all-or-nothing property is deliberate for a destructive batch — but it now
|
|
35
|
+
names the relation and everything depending on it instead of surfacing a raw
|
|
36
|
+
driver error, and records the blockage in the audit log.
|
|
37
|
+
|
|
38
|
+
- `dp db describe <relation>` raised nothing but returned an invalid result for
|
|
39
|
+
a non-view relation: `pg_get_viewdef()` yields NULL rather than erroring, and
|
|
40
|
+
the PostgreSQL branch returned a view definition whose `sql` was `None`,
|
|
41
|
+
violating its own annotation. It now raises, as the Redshift branch already
|
|
42
|
+
did.
|
|
43
|
+
|
|
44
|
+
- `dp db long-queries --history` died with a driver traceback on any server
|
|
45
|
+
where `pg_stat_statements` is installed but not preloaded — the most common
|
|
46
|
+
misconfiguration. The guard that was meant to catch this probed the view's
|
|
47
|
+
columns, which PostgreSQL answers from the view definition without invoking
|
|
48
|
+
the extension, so the probe always succeeded. The failure is now reported as
|
|
49
|
+
an actionable error naming `shared_preload_libraries` and the required
|
|
50
|
+
restart.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `RoleAttributes.password_set` widened from `bool` to `bool | None`, where
|
|
55
|
+
`None` means "not determinable by this connection". Relevant only if you
|
|
56
|
+
import the dataclass; the CLI renders the third state as `unknown`.
|
|
57
|
+
|
|
58
|
+
- On PostgreSQL, `dp db describe <schema>` privileges now come from the schema
|
|
59
|
+
ACL rather than a `has_schema_privilege` scan. A role holding `CREATE` only
|
|
60
|
+
through membership in a granted role is no longer listed as its own row, and
|
|
61
|
+
`grantor` and `WITH GRANT OPTION` are now real values where the previous
|
|
62
|
+
CREATE half hardcoded them.
|
|
63
|
+
|
|
3
64
|
## 0.2.1
|
|
4
65
|
|
|
5
66
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataplat
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: One command to manage any shape of data platform: databases, ingestion, BI, cloud, and CI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/hanslemm/dataplat
|
|
6
6
|
Project-URL: Repository, https://github.com/hanslemm/dataplat
|
|
@@ -20,6 +20,8 @@ from dataplat.services.db.connection import SqlEngine
|
|
|
20
20
|
from dataplat.services.db.orphans import (
|
|
21
21
|
DEPRECATED_SUFFIX,
|
|
22
22
|
LIVE_STATUSES,
|
|
23
|
+
BlockedEntry,
|
|
24
|
+
DependentObjectsError,
|
|
23
25
|
DropEntry,
|
|
24
26
|
ObjectKind,
|
|
25
27
|
RenameEntry,
|
|
@@ -661,7 +663,19 @@ def purge_cmd(
|
|
|
661
663
|
help="Path to a file with one exclusion token per line.",
|
|
662
664
|
),
|
|
663
665
|
) -> None:
|
|
664
|
-
"""Permanently drop every object ending in _deprecated (irreversible).
|
|
666
|
+
"""Permanently drop every object ending in _deprecated (irreversible).
|
|
667
|
+
|
|
668
|
+
All-or-nothing per target, by design: every drop for one warehouse runs
|
|
669
|
+
inside a single transaction, so if the warehouse refuses one of them
|
|
670
|
+
nothing is dropped for that target and the error names the blocking
|
|
671
|
+
relation. Keeping the drops that already succeeded would need either a
|
|
672
|
+
SAVEPOINT per object (Redshift has none) or a commit per object (which
|
|
673
|
+
would break the guarantee that --dry-run writes nothing) — and for a
|
|
674
|
+
destructive batch, stopping on the first surprise is the safer default. No
|
|
675
|
+
CASCADE is issued either, so a live view or foreign key still pointing at a
|
|
676
|
+
_deprecated relation is a real signal: resolve the dependents the error
|
|
677
|
+
lists, then re-run.
|
|
678
|
+
"""
|
|
665
679
|
if log is None:
|
|
666
680
|
log = _timestamped_log_path(PURGE_LOG_PREFIX)
|
|
667
681
|
|
|
@@ -691,24 +705,48 @@ def purge_cmd(
|
|
|
691
705
|
)
|
|
692
706
|
|
|
693
707
|
all_drops: list[DropEntry] = []
|
|
708
|
+
blocked: list[BlockedEntry] = []
|
|
694
709
|
try:
|
|
695
710
|
for label, engine, env_prefix in engines:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
711
|
+
try:
|
|
712
|
+
all_drops.extend(
|
|
713
|
+
_purge_for_engine(
|
|
714
|
+
label,
|
|
715
|
+
engine,
|
|
716
|
+
env_prefix=env_prefix,
|
|
717
|
+
excluded_user_schemas=excluded_user_schemas,
|
|
718
|
+
excluded_user_relations=excluded_user_relations,
|
|
719
|
+
dry_run=dry_run,
|
|
720
|
+
renamed_at=renamed_at,
|
|
721
|
+
cutoff=cutoff,
|
|
722
|
+
include_unknown=include_unknown,
|
|
723
|
+
)
|
|
707
724
|
)
|
|
708
|
-
|
|
725
|
+
except DependentObjectsError as exc:
|
|
726
|
+
# The engine's transaction is already rolled back, so the drops
|
|
727
|
+
# it had made are (correctly) absent from all_drops. Record the
|
|
728
|
+
# refused attempt so the audit log shows why this target did
|
|
729
|
+
# nothing, then re-raise with the [<engine>] prefix every other
|
|
730
|
+
# error from this command carries.
|
|
731
|
+
blocked.append(
|
|
732
|
+
BlockedEntry(
|
|
733
|
+
database=label,
|
|
734
|
+
schema=exc.schema,
|
|
735
|
+
name=exc.name,
|
|
736
|
+
kind=exc.kind,
|
|
737
|
+
dependents=exc.dependents,
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
raise ServiceError(f"[{label}] {exc}") from exc
|
|
709
741
|
except ServiceError as exc:
|
|
710
|
-
_write_purge_log(log, all_drops, dry_run=dry_run)
|
|
742
|
+
_write_purge_log(log, all_drops, dry_run=dry_run, blocked=blocked)
|
|
711
743
|
console.print(f"[red]{esc(exc)}[/red]")
|
|
744
|
+
if blocked:
|
|
745
|
+
console.print(
|
|
746
|
+
"[yellow]Nothing was dropped for that target: the purge is one "
|
|
747
|
+
"transaction, so a refused drop rolls the whole batch "
|
|
748
|
+
"back.[/yellow]"
|
|
749
|
+
)
|
|
712
750
|
console.print(
|
|
713
751
|
f"[yellow]Partial purge log written to {esc(log)} "
|
|
714
752
|
f"({len(all_drops)} entries).[/yellow]"
|
|
@@ -839,12 +877,22 @@ def _drop_one(
|
|
|
839
877
|
return DropEntry(database=label, schema=schema, name=name, kind=kind)
|
|
840
878
|
|
|
841
879
|
|
|
842
|
-
def _write_purge_log(
|
|
880
|
+
def _write_purge_log(
|
|
881
|
+
log_path: str,
|
|
882
|
+
entries: list[DropEntry],
|
|
883
|
+
*,
|
|
884
|
+
dry_run: bool,
|
|
885
|
+
blocked: list[BlockedEntry] | None = None,
|
|
886
|
+
) -> None:
|
|
843
887
|
payload = {
|
|
844
888
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
845
889
|
"dry_run": dry_run,
|
|
846
890
|
"source": "dbt-orphans-purge",
|
|
847
891
|
"drops": entries,
|
|
892
|
+
# A refused drop rolls its target's transaction back, so "drops" cannot
|
|
893
|
+
# hold the attempt; recorded separately or the log would show a purge
|
|
894
|
+
# that raised as having done nothing at all.
|
|
895
|
+
"blocked": blocked or [],
|
|
848
896
|
}
|
|
849
897
|
with open(log_path, "w") as f:
|
|
850
898
|
json.dump(payload, f, indent=4)
|
|
@@ -72,6 +72,19 @@ def _more_line(hidden: int) -> str:
|
|
|
72
72
|
return f" [dim italic]… and {hidden} more (raise --limit to see all).[/dim italic]"
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _password_set_value(password_set: bool | None) -> str:
|
|
76
|
+
"""Render the tri-state ``password_set`` as markup.
|
|
77
|
+
|
|
78
|
+
``None`` means the server refused to say (pg_authid is superuser-only), and
|
|
79
|
+
the reason travels with the word: a bare "unknown" in a security report
|
|
80
|
+
reads as a tool defect, and rendering it as "no" would be a false negative
|
|
81
|
+
on exactly the field an auditor came for.
|
|
82
|
+
"""
|
|
83
|
+
if password_set is None:
|
|
84
|
+
return "unknown [dim](needs superuser to read pg_authid)[/dim]"
|
|
85
|
+
return "yes" if password_set else "no"
|
|
86
|
+
|
|
87
|
+
|
|
75
88
|
def _attributes_metadata(attrs: RoleAttributes) -> list[tuple[str, str]]:
|
|
76
89
|
flags: list[str] = []
|
|
77
90
|
if attrs.superuser:
|
|
@@ -97,7 +110,11 @@ def _attributes_metadata(attrs: RoleAttributes) -> list[tuple[str, str]]:
|
|
|
97
110
|
# The title card renders metadata values as markup, and this one is
|
|
98
111
|
# warehouse data (rolvaliduntil rendered by the driver).
|
|
99
112
|
metadata.append(("Valid until", esc(attrs.valid_until)))
|
|
100
|
-
|
|
113
|
+
# `is True` on purpose: password_set is tri-state, and the card has room
|
|
114
|
+
# only for a bare word. An unexplained "unknown" chip is worse than no
|
|
115
|
+
# chip, so the unknown case is left to the Attributes table, which has
|
|
116
|
+
# room to say why.
|
|
117
|
+
if attrs.password_set is True:
|
|
101
118
|
metadata.append(("Password", "set"))
|
|
102
119
|
return metadata
|
|
103
120
|
|
|
@@ -122,7 +139,7 @@ def _render_attributes(
|
|
|
122
139
|
"Connection limit",
|
|
123
140
|
"unlimited" if attrs.connection_limit < 0 else str(attrs.connection_limit),
|
|
124
141
|
)
|
|
125
|
-
table.add_row("Password set",
|
|
142
|
+
table.add_row("Password set", _password_set_value(attrs.password_set))
|
|
126
143
|
table.add_row("Valid until", cell(attrs.valid_until or "—"))
|
|
127
144
|
console.print(_indent(table))
|
|
128
145
|
|
|
@@ -665,6 +665,12 @@ def fetch_view_definition(cursor: Any, oid: int, engine: SqlEngine) -> ViewDefin
|
|
|
665
665
|
if row is None:
|
|
666
666
|
raise TargetNotFoundError(f"view with oid {oid} not found")
|
|
667
667
|
sql, is_updatable, check_option = row
|
|
668
|
+
# pg_get_viewdef() does not error on a non-view oid, it returns NULL, so a
|
|
669
|
+
# table's oid produces a row whose definition is missing. Reject it the way
|
|
670
|
+
# the Redshift branch above does: returning ViewDefinition(sql=None) would
|
|
671
|
+
# break this dataclass's own `sql: str` contract for every caller.
|
|
672
|
+
if sql is None:
|
|
673
|
+
raise TargetNotFoundError(f"view with oid {oid} not found")
|
|
668
674
|
updatable = isinstance(is_updatable, str) and is_updatable.upper() == "YES"
|
|
669
675
|
if check_option is None or check_option == "NONE":
|
|
670
676
|
check_option_value: str | None = None
|
|
@@ -752,7 +758,40 @@ ORDER BY c.relkind, c.relname
|
|
|
752
758
|
"""
|
|
753
759
|
|
|
754
760
|
|
|
755
|
-
|
|
761
|
+
# PostgreSQL exposes schema ACLs only through pg_namespace.nspacl; there is no
|
|
762
|
+
# information_schema view for them. usage_privileges reports DOMAIN, COLLATION,
|
|
763
|
+
# FDW, foreign server and sequence object types and never 'SCHEMA', so the
|
|
764
|
+
# USAGE half of the old shared query silently matched zero rows and every
|
|
765
|
+
# GRANT USAGE ON SCHEMA was missing from the report.
|
|
766
|
+
#
|
|
767
|
+
# The COALESCE is load-bearing: nspacl stays NULL until the first GRANT or
|
|
768
|
+
# REVOKE touches the schema, and NULL means "the built-in default" -- the owner
|
|
769
|
+
# holding USAGE + CREATE -- not "nobody holds anything". acldefault('n', owner)
|
|
770
|
+
# materialises that default so a freshly created schema still reports its
|
|
771
|
+
# owner's privileges.
|
|
772
|
+
#
|
|
773
|
+
# Reading the ACL also makes grantor and WITH GRANT OPTION truthful: the CREATE
|
|
774
|
+
# half of the old query derived grantees from has_schema_privilege() and had to
|
|
775
|
+
# hardcode grantor='' and with_grant_option=false.
|
|
776
|
+
_SCHEMA_PRIVILEGES_SQL_POSTGRES = """
|
|
777
|
+
SELECT
|
|
778
|
+
CASE WHEN (acl).grantee = 0 THEN 'PUBLIC'
|
|
779
|
+
ELSE pg_get_userbyid((acl).grantee) END AS grantee,
|
|
780
|
+
(acl).privilege_type AS privilege_type,
|
|
781
|
+
(acl).is_grantable AS with_grant_option,
|
|
782
|
+
pg_get_userbyid((acl).grantor) AS grantor
|
|
783
|
+
FROM pg_namespace n
|
|
784
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) AS acl
|
|
785
|
+
WHERE n.nspname = %s
|
|
786
|
+
ORDER BY grantee, privilege_type
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# Unchanged from when this query was shared by both engines, and deliberately
|
|
791
|
+
# NOT switched to the aclexplode form above: Redshift has no aclexplode() and
|
|
792
|
+
# no cluster is available to test a replacement against. Its USAGE reporting is
|
|
793
|
+
# therefore still unverified and probably as empty as PostgreSQL's was.
|
|
794
|
+
_SCHEMA_PRIVILEGES_SQL_REDSHIFT = """
|
|
756
795
|
SELECT grantee, privilege_type, is_grantable = 'YES', grantor
|
|
757
796
|
FROM information_schema.usage_privileges
|
|
758
797
|
WHERE object_schema = %s AND object_type = 'SCHEMA'
|
|
@@ -820,9 +859,19 @@ def fetch_schema_contents(
|
|
|
820
859
|
]
|
|
821
860
|
|
|
822
861
|
|
|
823
|
-
def fetch_schema_privileges(
|
|
824
|
-
|
|
825
|
-
|
|
862
|
+
def fetch_schema_privileges(
|
|
863
|
+
cursor: Any, schema: str, engine: SqlEngine
|
|
864
|
+
) -> list[PrivilegeGrant]:
|
|
865
|
+
"""Return USAGE + CREATE grants for the schema.
|
|
866
|
+
|
|
867
|
+
On PostgreSQL these come from ``pg_namespace.nspacl``, which lists explicit
|
|
868
|
+
ACL entries plus the owner's implicit default; a role that only holds the
|
|
869
|
+
privilege through membership in a granted role is not a separate row.
|
|
870
|
+
"""
|
|
871
|
+
if engine == SqlEngine.redshift:
|
|
872
|
+
cursor.execute(_SCHEMA_PRIVILEGES_SQL_REDSHIFT, (schema, schema))
|
|
873
|
+
else:
|
|
874
|
+
cursor.execute(_SCHEMA_PRIVILEGES_SQL_POSTGRES, (schema,))
|
|
826
875
|
return [PrivilegeGrant(*row) for row in cursor.fetchall()]
|
|
827
876
|
|
|
828
877
|
|
|
@@ -1044,7 +1093,7 @@ def describe_schema(
|
|
|
1044
1093
|
) -> SchemaDescription:
|
|
1045
1094
|
"""Compose a full schema description by invoking each fetcher."""
|
|
1046
1095
|
header = fetch_schema_header(cursor, ref.schema)
|
|
1047
|
-
privileges = fetch_schema_privileges(cursor, ref.schema)
|
|
1096
|
+
privileges = fetch_schema_privileges(cursor, ref.schema, engine)
|
|
1048
1097
|
default_privileges = fetch_schema_default_privileges(cursor, ref.schema, engine)
|
|
1049
1098
|
contents = fetch_schema_contents(cursor, ref.schema, engine)
|
|
1050
1099
|
return SchemaDescription(
|
|
@@ -294,6 +294,21 @@ class QueryHistoryRow:
|
|
|
294
294
|
query_text: str
|
|
295
295
|
|
|
296
296
|
|
|
297
|
+
# SQLSTATE 55000 (object_not_in_prerequisite_state). pg_stat_statements' C
|
|
298
|
+
# function raises it — "must be loaded via shared_preload_libraries" — when the
|
|
299
|
+
# extension exists in the catalog but was never preloaded. Matched by code
|
|
300
|
+
# rather than by message text or by exception class: the message is not part of
|
|
301
|
+
# any contract, and this module deliberately never imports psycopg so it stays
|
|
302
|
+
# drivable by the fake cursors the unit suite uses.
|
|
303
|
+
_NOT_LOADED_SQLSTATE = "55000"
|
|
304
|
+
|
|
305
|
+
_PGSS_NOT_LOADED = (
|
|
306
|
+
"pg_stat_statements is installed but not loaded. Add it to "
|
|
307
|
+
"shared_preload_libraries in postgresql.conf and RESTART the server "
|
|
308
|
+
"(a config reload is not enough — it is a preloaded library)."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
297
312
|
def fetch_query_history_postgres(
|
|
298
313
|
cursor: Any,
|
|
299
314
|
*,
|
|
@@ -305,7 +320,8 @@ def fetch_query_history_postgres(
|
|
|
305
320
|
Column names changed across pg_stat_statements versions
|
|
306
321
|
(``total_time`` -> ``total_exec_time``), so they are discovered from the
|
|
307
322
|
result-set description. Raises :class:`ValidationError` when the
|
|
308
|
-
extension's columns are missing/incompatible
|
|
323
|
+
extension's columns are missing/incompatible, and when the extension is
|
|
324
|
+
present but was never preloaded.
|
|
309
325
|
"""
|
|
310
326
|
cursor.execute("SELECT * FROM pg_stat_statements LIMIT 0")
|
|
311
327
|
column_names = {desc.name.lower() for desc in (cursor.description or [])}
|
|
@@ -335,7 +351,18 @@ def fetch_query_history_postgres(
|
|
|
335
351
|
ORDER BY {_quote(max_col)} DESC
|
|
336
352
|
LIMIT %s
|
|
337
353
|
"""
|
|
338
|
-
|
|
354
|
+
# The column probe above cannot detect a non-preloaded extension: Postgres
|
|
355
|
+
# answers `SELECT * FROM pg_stat_statements LIMIT 0` from the view
|
|
356
|
+
# definition alone, never invoking the extension's C function, so discovery
|
|
357
|
+
# returns the full and correct column list and only this read fails. Left
|
|
358
|
+
# unhandled it reaches the user as a traceback instead of the actionable
|
|
359
|
+
# error the probe was meant to produce.
|
|
360
|
+
try:
|
|
361
|
+
cursor.execute(sql, (min_seconds, limit))
|
|
362
|
+
except Exception as exc: # narrowed to SQLSTATE 55000 immediately below
|
|
363
|
+
if getattr(exc, "sqlstate", None) != _NOT_LOADED_SQLSTATE:
|
|
364
|
+
raise
|
|
365
|
+
raise ValidationError(_PGSS_NOT_LOADED) from exc
|
|
339
366
|
return [
|
|
340
367
|
QueryHistoryRow(
|
|
341
368
|
calls=int(row[0]),
|
|
@@ -17,7 +17,7 @@ from typing import Any, Literal, TypedDict
|
|
|
17
17
|
import psycopg
|
|
18
18
|
from psycopg import sql
|
|
19
19
|
|
|
20
|
-
from dataplat.core.errors import ConfigError
|
|
20
|
+
from dataplat.core.errors import ConfigError, ServiceError
|
|
21
21
|
from dataplat.services.db._like import LIKE_ESCAPE_CLAUSE, like_escape
|
|
22
22
|
from dataplat.services.db.connection import (
|
|
23
23
|
DbConnectionParams,
|
|
@@ -105,6 +105,45 @@ class DropEntry(TypedDict):
|
|
|
105
105
|
kind: ObjectKind
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
class BlockedEntry(TypedDict):
|
|
109
|
+
"""Serialized record of a drop the warehouse refused, written to the purge log.
|
|
110
|
+
|
|
111
|
+
A refused drop rolls its whole purge transaction back, so the attempt
|
|
112
|
+
leaves no trace in ``drops``. Without this the audit log would show the
|
|
113
|
+
purge as a silent no-op for that warehouse.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
database: str
|
|
117
|
+
schema: str
|
|
118
|
+
name: str
|
|
119
|
+
kind: ObjectKind
|
|
120
|
+
dependents: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DependentObjectsError(ServiceError):
|
|
124
|
+
"""A ``DROP`` was refused because other objects still depend on the relation.
|
|
125
|
+
|
|
126
|
+
Carries the blocked relation and the server's own dependency listing so the
|
|
127
|
+
caller can name both in its error output and its audit log, instead of
|
|
128
|
+
surfacing a bare ``psycopg.errors.DependentObjectsStillExist`` that says
|
|
129
|
+
nothing the user can act on.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self, schema: str, name: str, kind: ObjectKind, dependents: str
|
|
134
|
+
) -> None:
|
|
135
|
+
self.schema = schema
|
|
136
|
+
self.name = name
|
|
137
|
+
self.kind = kind
|
|
138
|
+
self.dependents = dependents
|
|
139
|
+
blockers = f" ({dependents})" if dependents else ""
|
|
140
|
+
super().__init__(
|
|
141
|
+
f"Cannot drop {kind} {schema}.{name}: other objects still depend "
|
|
142
|
+
f"on it{blockers}. Drop or repoint the dependent object(s), then "
|
|
143
|
+
f"re-run the purge."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
108
147
|
def resolve_orphans_connection_params(
|
|
109
148
|
engine: SqlEngine, *, env_prefix: str
|
|
110
149
|
) -> DbConnectionParams | None:
|
|
@@ -238,15 +277,22 @@ def rename_object(
|
|
|
238
277
|
)
|
|
239
278
|
|
|
240
279
|
|
|
280
|
+
# ``IF EXISTS`` is dialect-safe: PostgreSQL and Redshift both accept it on all
|
|
281
|
+
# three kinds this drops, so the keyword can be shared. It is load-bearing
|
|
282
|
+
# because the purge runs every drop of one warehouse inside a single
|
|
283
|
+
# transaction — without it, a relation that vanished between the scan and its
|
|
284
|
+
# DROP raised UndefinedTable and rolled back the drops that had already
|
|
285
|
+
# succeeded. ``IF EXISTS`` only suppresses "does not exist"; a wrong-kind
|
|
286
|
+
# keyword still errors, so each kind keeps its own DROP.
|
|
241
287
|
_DROP_KEYWORDS: dict[ObjectKind, str] = {
|
|
242
|
-
"table": "DROP TABLE",
|
|
243
|
-
"view": "DROP VIEW",
|
|
244
|
-
"matview": "DROP MATERIALIZED VIEW",
|
|
288
|
+
"table": "DROP TABLE IF EXISTS",
|
|
289
|
+
"view": "DROP VIEW IF EXISTS",
|
|
290
|
+
"matview": "DROP MATERIALIZED VIEW IF EXISTS",
|
|
245
291
|
}
|
|
246
292
|
|
|
247
293
|
|
|
248
294
|
def build_drop_statement(schema: str, name: str, kind: ObjectKind) -> sql.Composed:
|
|
249
|
-
"""Build a ``DROP
|
|
295
|
+
"""Build a ``DROP … IF EXISTS`` statement for the given object kind."""
|
|
250
296
|
return sql.SQL("{keyword} {schema}.{name}").format(
|
|
251
297
|
keyword=sql.SQL(_DROP_KEYWORDS[kind]),
|
|
252
298
|
schema=sql.Identifier(schema),
|
|
@@ -254,8 +300,31 @@ def build_drop_statement(schema: str, name: str, kind: ObjectKind) -> sql.Compos
|
|
|
254
300
|
)
|
|
255
301
|
|
|
256
302
|
|
|
303
|
+
def _dependents_detail(exc: psycopg.Error) -> str:
|
|
304
|
+
"""Flatten the server's dependency ``DETAIL`` onto one line.
|
|
305
|
+
|
|
306
|
+
The server already names every dependent object, so no extra catalog query
|
|
307
|
+
(which would have to be written twice for Redshift) is needed. The DETAIL
|
|
308
|
+
is one line per dependent, and the CLI prints errors as single Rich lines.
|
|
309
|
+
"""
|
|
310
|
+
detail = (exc.diag.message_detail or "").strip()
|
|
311
|
+
return "; ".join(line.strip() for line in detail.splitlines() if line.strip())
|
|
312
|
+
|
|
313
|
+
|
|
257
314
|
def drop_object(cur: Any, schema: str, name: str, kind: ObjectKind) -> None:
|
|
258
|
-
|
|
315
|
+
"""Drop ``schema.name``, reporting a dependency refusal in actionable terms.
|
|
316
|
+
|
|
317
|
+
No ``CASCADE`` is issued, so a live view or foreign key still pointing at
|
|
318
|
+
the relation is a legitimate blocker rather than something to bulldoze —
|
|
319
|
+
but the raw ``DependentObjectsStillExist`` names neither the relation nor
|
|
320
|
+
the blockers once it has bubbled up through the purge, so translate it.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
cur.execute(build_drop_statement(schema, name, kind))
|
|
324
|
+
except psycopg.errors.DependentObjectsStillExist as exc:
|
|
325
|
+
raise DependentObjectsError(
|
|
326
|
+
schema, name, kind, _dependents_detail(exc)
|
|
327
|
+
) from exc
|
|
259
328
|
|
|
260
329
|
|
|
261
330
|
def fetch_deprecated_objects(
|
|
@@ -93,15 +93,51 @@ class RoleAttributes:
|
|
|
93
93
|
replication: bool
|
|
94
94
|
bypass_rls: bool
|
|
95
95
|
connection_limit: int
|
|
96
|
-
|
|
96
|
+
# Tri-state on PostgreSQL: True / False when the real password store was
|
|
97
|
+
# readable, None for "cannot determine" when it was not. Only pg_authid
|
|
98
|
+
# knows, and it is superuser-only, so a plain bool would have to guess --
|
|
99
|
+
# and for a field an auditor reads, "unknown" beats a confident guess.
|
|
100
|
+
password_set: bool | None
|
|
97
101
|
valid_until: str | None
|
|
98
102
|
|
|
99
103
|
|
|
104
|
+
# pg_roles.rolpassword is the literal '********' for every row -- the view
|
|
105
|
+
# definition hard-codes it -- so `rolpassword IS NOT NULL` asked there is
|
|
106
|
+
# always true and says nothing about whether a password exists. pg_authid
|
|
107
|
+
# holds the real verifier and is superuser-only by default.
|
|
108
|
+
#
|
|
109
|
+
# Naming pg_authid at all is what fails: the executor checks privileges for
|
|
110
|
+
# every range table before it runs anything, so no CASE branch or unexecuted
|
|
111
|
+
# subquery can hide the reference. The resulting error aborts the transaction,
|
|
112
|
+
# which would also kill the membership / ownership / privilege queries
|
|
113
|
+
# describe_role issues after this one. Asking first avoids the error entirely:
|
|
114
|
+
# has_table_privilege() is an ordinary function call needing no privileges. It
|
|
115
|
+
# also answers the real question rather than "am I superuser" -- a role that
|
|
116
|
+
# was explicitly GRANTed SELECT on pg_authid gets the true answer.
|
|
117
|
+
_PG_AUTHID_READABLE_SQL = """
|
|
118
|
+
SELECT has_table_privilege('pg_authid', 'SELECT')
|
|
119
|
+
"""
|
|
120
|
+
|
|
100
121
|
_ATTRS_SQL_POSTGRES = """
|
|
122
|
+
SELECT r.rolcanlogin, r.rolsuper, r.rolcreatedb, r.rolcreaterole,
|
|
123
|
+
r.rolinherit, r.rolreplication, r.rolbypassrls,
|
|
124
|
+
r.rolconnlimit,
|
|
125
|
+
(a.rolpassword IS NOT NULL) AS password_set,
|
|
126
|
+
r.rolvaliduntil::text
|
|
127
|
+
FROM pg_roles r
|
|
128
|
+
JOIN pg_authid a ON a.oid = r.oid
|
|
129
|
+
WHERE r.rolname = %s
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# Same columns in the same order as _ATTRS_SQL_POSTGRES so one unpack serves
|
|
133
|
+
# both, with password_set pinned to NULL. pg_roles is a view over pg_authid
|
|
134
|
+
# and exposes every row of it, so dropping the join loses nothing but the one
|
|
135
|
+
# column this session may not read.
|
|
136
|
+
_ATTRS_SQL_POSTGRES_NO_AUTHID = """
|
|
101
137
|
SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
|
|
102
138
|
rolinherit, rolreplication, rolbypassrls,
|
|
103
139
|
rolconnlimit,
|
|
104
|
-
|
|
140
|
+
NULL::boolean AS password_set,
|
|
105
141
|
rolvaliduntil::text
|
|
106
142
|
FROM pg_roles
|
|
107
143
|
WHERE rolname = %s
|
|
@@ -114,6 +150,17 @@ WHERE usename = %s
|
|
|
114
150
|
"""
|
|
115
151
|
|
|
116
152
|
|
|
153
|
+
def _pg_authid_readable(cursor: Any) -> bool:
|
|
154
|
+
"""True when this session may ``SELECT`` from ``pg_authid``.
|
|
155
|
+
|
|
156
|
+
Split out so the "cannot determine" branch of ``fetch_attributes`` is
|
|
157
|
+
reachable in a test without giving up superuser on the connection.
|
|
158
|
+
"""
|
|
159
|
+
cursor.execute(_PG_AUTHID_READABLE_SQL)
|
|
160
|
+
row = cursor.fetchone()
|
|
161
|
+
return bool(row and row[0])
|
|
162
|
+
|
|
163
|
+
|
|
117
164
|
def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttributes:
|
|
118
165
|
if engine == SqlEngine.redshift:
|
|
119
166
|
cursor.execute(_ATTRS_SQL_REDSHIFT, (name,))
|
|
@@ -145,7 +192,11 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
|
|
|
145
192
|
password_set=False,
|
|
146
193
|
valid_until=str(valid_until) if valid_until is not None else None,
|
|
147
194
|
)
|
|
148
|
-
|
|
195
|
+
authid_readable = _pg_authid_readable(cursor)
|
|
196
|
+
cursor.execute(
|
|
197
|
+
_ATTRS_SQL_POSTGRES if authid_readable else _ATTRS_SQL_POSTGRES_NO_AUTHID,
|
|
198
|
+
(name,),
|
|
199
|
+
)
|
|
149
200
|
row = cursor.fetchone()
|
|
150
201
|
if row is None:
|
|
151
202
|
raise RoleNotFoundError(f'role "{name}" not found')
|
|
@@ -170,7 +221,9 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
|
|
|
170
221
|
replication=bool(replication),
|
|
171
222
|
bypass_rls=bool(bypass_rls),
|
|
172
223
|
connection_limit=int(conn_limit),
|
|
173
|
-
|
|
224
|
+
# Keyed off the probe, not off the value: only the probe distinguishes
|
|
225
|
+
# "the store says no password" from "we were not allowed to look".
|
|
226
|
+
password_set=bool(password_set) if authid_readable else None,
|
|
174
227
|
valid_until=valid_until,
|
|
175
228
|
)
|
|
176
229
|
|
|
@@ -355,7 +408,13 @@ def fetch_owned_objects(
|
|
|
355
408
|
total = 0
|
|
356
409
|
for schema, relkind, count in cursor.fetchall():
|
|
357
410
|
kind_label = _KIND_LABEL.get(relkind, relkind)
|
|
358
|
-
by_schema.setdefault(schema, {})
|
|
411
|
+
kinds = by_schema.setdefault(schema, {})
|
|
412
|
+
# Accumulate, never assign: the query groups by relkind while
|
|
413
|
+
# _KIND_LABEL folds several relkinds onto one label ('r' and 'p' are
|
|
414
|
+
# both "table"), so one schema can produce two rows for the same label.
|
|
415
|
+
# Assigning let the second row overwrite the first, which under-counted
|
|
416
|
+
# the breakdown and made it disagree with total_relations below.
|
|
417
|
+
kinds[kind_label] = kinds.get(kind_label, 0) + int(count)
|
|
359
418
|
total += int(count)
|
|
360
419
|
return OwnedObjectsSummary(
|
|
361
420
|
schemas=schemas,
|
|
@@ -142,6 +142,9 @@ def test_db_role_cli_smoke() -> None:
|
|
|
142
142
|
cursor.fetchone.side_effect = [
|
|
143
143
|
# resolve_role
|
|
144
144
|
(16384, True, False),
|
|
145
|
+
# pg_authid readable? password_set is only knowable to a superuser, so
|
|
146
|
+
# fetch_attributes probes for the privilege before choosing its query.
|
|
147
|
+
(True,),
|
|
145
148
|
# attributes
|
|
146
149
|
(True, False, True, True, True, False, False, -1, False, None),
|
|
147
150
|
]
|
|
@@ -250,6 +253,8 @@ def test_db_role_cli_json_flag() -> None:
|
|
|
250
253
|
cursor = MagicMock()
|
|
251
254
|
cursor.fetchone.side_effect = [
|
|
252
255
|
(16384, True, False),
|
|
256
|
+
# pg_authid privilege probe (see the smoke test above).
|
|
257
|
+
(True,),
|
|
253
258
|
(True, False, True, True, True, False, False, -1, False, None),
|
|
254
259
|
]
|
|
255
260
|
cursor.fetchall.side_effect = [
|