Python scripts for async batch privilege scraping
This page gives you copy-ready Python that scrapes the privilege state of many databases concurrently — without exhausting connection pools, blocking DDL, or emitting non-deterministic output that shows up downstream as phantom drift.
When to reach for async batch scraping
Async batching is the right tool in a narrow but common set of conditions. Use it when all three hold; pick something simpler otherwise.
- Use it when you must enumerate grants across tens or hundreds of database instances on a schedule, and a serial synchronous sweep already causes scan-induced contention (saturated pools, catalog-lock waits, application query timeouts).
- Use it when the extraction account is strictly read-only and every batch must be reproducible — a repeated run against an unchanged catalog has to produce byte-identical output so the diff engine sees zero drift.
- Skip it when you are scraping a single small instance, or when downstream processing is the bottleneck rather than acquisition — in those cases the extra concurrency machinery buys nothing, and a plain
psycopgloop is easier to audit.
This page is the concrete implementation of one technique within Async Privilege Batching; the acquisition rows it produces flow into Cross-Environment Privilege Extraction & Parsing for normalization.
Step-by-step implementation
The scraper has five moving parts: a read-only extraction role, a chunk planner, a bounded-concurrency fetcher, a retry wrapper for transient failures, and a deterministic payload emitter. Build them in order and verify each before moving on.
1. Provision a read-only extraction role
The safety argument for the whole pipeline rests on the scraping principal holding no DDL and no GRANT. Create a dedicated role with catalog read access only. On PostgreSQL:
CREATE ROLE drift_scraper LOGIN PASSWORD 'REDACTED';
GRANT pg_read_all_stats TO drift_scraper; -- catalog + stats visibility
GRANT pg_read_all_data TO drift_scraper; -- widens object-grant visibility (PG 14+)
On MySQL 8.0+, where the role graph lives in mysql.role_edges:
CREATE USER 'drift_scraper'@'%' IDENTIFIED BY 'REDACTED';
GRANT SELECT ON mysql.role_edges TO 'drift_scraper'@'%';
GRANT SELECT ON `information_schema`.* TO 'drift_scraper'@'%';
Verify the role cannot mutate state:
-- PostgreSQL: expect zero rows; the scraper must own no grantable privileges.
SELECT grantee, table_schema, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'drift_scraper' AND is_grantable = 'YES';
2. Partition each environment into non-overlapping chunks
A chunk is the smallest unit the concurrency limiter schedules. Chunking by schema keeps each query bounded, lets a single failed chunk retry without re-reading the whole catalog, and makes batch identity stable. The planner reads only the schema list — a cheap query — and skips transient namespaces so they never pollute a canonical baseline:
SELECT n.nspname AS schema_name
FROM pg_catalog.pg_namespace n
WHERE n.nspname NOT LIKE 'pg_temp_%'
AND n.nspname NOT LIKE 'pg_toast_temp_%'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname;
The exact shape of the per-chunk query — keyset pagination, index-friendly predicates, avoiding OFFSET — is governed by System Catalog Query Optimization; the scraper simply dispatches whatever tuned query that work produces.
3. Fetch chunks with bounded concurrency
asyncio.Semaphore caps how many catalog reads run at once, kept below the pool ceiling so application traffic is never starved. Each fetch runs inside a repeatable_read, readonly transaction so the snapshot is internally consistent and provably non-mutating:
import asyncio
import asyncpg
import hashlib
import json
from datetime import datetime, timezone
CATALOG_QUERY = """
SELECT grantee, table_schema, table_name, privilege_type, is_grantable
FROM information_schema.role_table_grants
WHERE table_schema = $1
AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY grantee, table_name, privilege_type
"""
async def fetch_schema_grants(pool: asyncpg.Pool, schema: str, batch_id: str) -> dict:
"""Fetch all grants for one schema inside a read-only transaction."""
async with pool.acquire() as conn:
async with conn.transaction(isolation="repeatable_read", readonly=True):
rows = await conn.fetch(CATALOG_QUERY, schema)
records = [dict(r) for r in rows]
return {
"batch_id": batch_id,
"schema": schema,
"extracted_at": datetime.now(timezone.utc).isoformat(),
"grants": records,
"checksum": hashlib.sha256(
json.dumps(records, sort_keys=True, default=str).encode()
).hexdigest(),
}
async def scrape_privileges(dsn: str, schemas: list[str], max_concurrency: int = 5) -> list[dict]:
"""Scrape grants for every schema with bounded concurrency.
Returns a deterministically ordered list of batch payloads."""
semaphore = asyncio.Semaphore(max_concurrency)
async with asyncpg.create_pool(dsn, min_size=2, max_size=max_concurrency + 2) as pool:
async def bounded_fetch(schema: str) -> dict:
batch_id = hashlib.sha256(
f"{schema}:{datetime.now(timezone.utc).date()}".encode()
).hexdigest()[:16]
async with semaphore:
return await fetch_schema_grants(pool, schema, batch_id)
tasks = [asyncio.create_task(bounded_fetch(s)) for s in schemas]
results = await asyncio.gather(*tasks, return_exceptions=True)
payloads = []
for schema, result in zip(schemas, results):
if isinstance(result, Exception):
print(f"ERROR schema={schema}: {result}") # log, don't abort the run
else:
payloads.append(result)
return sorted(payloads, key=lambda p: p["schema"])
Run it and confirm concurrency stays capped:
payloads = asyncio.run(scrape_privileges(dsn, ["app", "billing", "analytics"], max_concurrency=3))
# Expected: len(payloads) == number of reachable schemas; never more than
# max_concurrency connections active at once (watch pg_stat_activity).
4. Absorb transient failures with bounded retry
Catalog locks, connection resets, and network timeouts are transient and should retry with exponential backoff plus jitter; invalid credentials or missing views are permanent and must fail fast. Wrap only the fetch coroutine, and cap attempts so a dead host never blocks the event loop indefinitely:
import asyncio
import random
async def with_retry(coro_fn, max_attempts: int = 3, base_delay: float = 1.0):
"""Retry a coroutine with exponential backoff and jitter on transient errors."""
for attempt in range(1, max_attempts + 1):
try:
return await coro_fn()
except (asyncio.TimeoutError, OSError) as exc: # transient class only
if attempt == max_attempts:
raise
delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
await asyncio.sleep(delay)
To use it, wrap the fetch call inside bounded_fetch: return await with_retry(lambda: fetch_schema_grants(pool, schema, batch_id)). Permanent errors (an asyncpg.InvalidPasswordError, a missing information_schema view) fall straight through and land in the results list as exceptions for quarantine.
5. Emit a deterministic, checksummed payload for diffing
Each payload already carries a sha256 checksum over its sorted grant rows. That checksum is the contract with the diff engine: identical catalog state yields an identical checksum, so a no-op run is provably a no-op. Verify determinism by scraping the same schema twice:
a = asyncio.run(scrape_privileges(dsn, ["billing"], 1))[0]["checksum"]
b = asyncio.run(scrape_privileges(dsn, ["billing"], 1))[0]["checksum"]
assert a == b, "non-deterministic extraction — check ORDER BY and timestamp keys"
From here the batches leave the acquisition stage: vendor-specific grant strings are translated by Cross-DB Parser Adapters, and structural integrity is enforced by Schema Validation Pipelines before anything is persisted.
Worked example: three PostgreSQL 15 environments, one nightly sweep
Consider a topology of three PostgreSQL 15 instances — prod, staging, and dev — each holding the schemas app, billing, and analytics, scraped nightly by the drift_scraper role from step 1. The orchestration layer iterates environments and calls scrape_privileges per DSN with max_concurrency=3 (well under each instance’s 20-connection pool):
ENVIRONMENTS = {
"prod": "postgresql://drift_scraper@prod-db/appdb",
"staging": "postgresql://drift_scraper@stg-db/appdb",
"dev": "postgresql://drift_scraper@dev-db/appdb",
}
SCHEMAS = ["app", "billing", "analytics"]
async def sweep() -> dict:
out = {}
for env, dsn in ENVIRONMENTS.items():
out[env] = await scrape_privileges(dsn, SCHEMAS, max_concurrency=3)
return out
snapshot = asyncio.run(sweep())
A representative snapshot["prod"][1] payload:
{
"batch_id": "9f2c1a7b4e6d0c83",
"schema": "billing",
"extracted_at": "2026-07-04T02:00:07.412903+00:00",
"grants": [
{"grantee": "billing_ro", "table_schema": "billing",
"table_name": "invoices", "privilege_type": "SELECT", "is_grantable": "NO"},
{"grantee": "ci_service", "table_schema": "billing",
"table_name": "invoices", "privilege_type": "SELECT", "is_grantable": "NO"}
],
"checksum": "b1946ac92492d2347c6235b4d2611184..."
}
The nightly job persists each payload keyed by (env, schema). When the diff engine compares tonight’s checksum for prod/billing against last night’s, an unchanged catalog produces an identical hash and zero drift; the addition of a ci_service INSERT grant flips the checksum, and the row-level diff surfaces exactly that one added privilege — the granularity auditors expect from an evidence trail.
Gotchas and engine-specific notes
PostgreSQL vs MySQL role catalogs. PostgreSQL resolves object grants through information_schema.role_table_grants and role membership through pg_catalog.pg_auth_members. MySQL 8.0 stores role edges in mysql.role_edges, which does not exist before 8.0 — a scraper pointed at MySQL 5.7 must fall back to mysql.user and the *_priv columns, or it will raise a missing-table error that the retry wrapper will (correctly) treat as permanent.
Never wrap a sync driver in an executor. Calling loop.run_in_executor around psycopg2 re-introduces the thread-pool contention async is meant to remove and silently breaks backpressure. Use a genuinely async driver: asyncpg for PostgreSQL, aiomysql or asyncmy for MySQL. Any blocking call inside a coroutine stalls the whole event loop.
Timestamps must stay out of the identity key. extracted_at is provenance metadata, not part of what the checksum hashes — the checksum is computed over records only. If a timestamp leaks into the hashed set, every run drifts against the last and the diff engine drowns in false positives.
Keyset over OFFSET on large grant tables. On estates with very large role_table_grants, OFFSET-based pagination forces repeated sequential scans and lengthens catalog-lock windows. Page with WHERE oid > $last_seen_oid instead; see System Catalog Query Optimization for the full pattern.
Compliance note
Read-only async scraping is the evidence-collection mechanism for access-review controls: SOC 2 CC6.1 and CC6.3 (logical access is provisioned and reviewed against least privilege), PCI-DSS Requirement 7 (need-to-know restriction), and HIPAA §164.312(a)(1) (access control). The artifact it produces for auditors is a timestamped, checksummed per-environment privilege payload — the batch_id and sha256 fields give each snapshot a tamper-evident identity, and the diff between two dated snapshots is the periodic access-review record itself. Because the extraction principal holds no DDL or GRANT, the collection process cannot alter the state it attests to, which is the property an auditor checks first.
Frequently asked questions
Why bound concurrency with a semaphore instead of just launching every task?
Unbounded asyncio.gather opens one connection per schema per host and instantly exhausts the pool, which is the exact scan-induced contention this technique exists to prevent. The asyncio.Semaphore caps active catalog reads below the pool ceiling so application queries always have headroom.
How do I run this as a dry run against production?
It already is one. Every fetch executes inside a readonly=True transaction and the role from step 1 holds no write privileges, so there is no DDL or DML path. To confirm before a first run, execute the verification query in step 1 and expect zero grantable rows.
What makes the output safe to diff?
Determinism. The ORDER BY in the catalog query fixes row order, json.dumps(..., sort_keys=True) fixes key order, and the timestamp is excluded from the hashed set — so an unchanged catalog yields an identical checksum on every run.
Does this work for MySQL and Oracle too?
The concurrency and retry structure is engine-agnostic; only the driver and catalog query change. Swap asyncpg for aiomysql and query mysql.role_edges on MySQL 8.0+, or DBA_TAB_PRIVS / DBA_ROLE_PRIVS on Oracle. The per-dialect grant strings are reconciled downstream by the Cross-DB Parser Adapters.
Related
- Async Privilege Batching — the batching model, semaphore sizing, and staging-buffer design this script implements.
- System Catalog Query Optimization — how to shape the per-chunk catalog query for keyset pagination and lock avoidance.
- Cross-DB Parser Adapters — normalizing the vendor-specific grant strings each batch carries into canonical tuples.
- Schema Validation Pipelines — validating and gating batches before they reach the drift datastore.