feat(dlt): add a reusable database source, with Keycloak as its first instance#2472
feat(dlt): add a reusable database source, with Keycloak as its first instance#2472blarghmatey wants to merge 4 commits into
Conversation
… instance Migrating database sources off Airbyte one at a time only pays off if each one is a declaration rather than a new pipeline. A DatabaseSourceSpec of tables becomes a dlt source whose resources already carry the warehouse's raw-table names, so the existing Dagster translator and asset conventions apply unchanged. Two constraints shaped the credential path. Dagster instantiates every dlt source when the code location is imported, so anything resolved there is long stale by the time a scheduled run fires -- Vault credentials are therefore injected per connection through a SQLAlchemy do_connect hook, and table reflection is deferred so no connection is opened at import either. The deployed connection URL carries only a host and database name; a username or password never appears in it. Which database gets read follows the deployment rather than the spec, so local development reads the local-dev Postgres cluster and no developer needs access to QA or production to exercise a pipeline. Keycloak is the first instance: a curated identity subset, deliberately excluding the credential and session tables and dropping client secret columns, since Keycloak's remaining ~80 tables are its own bookkeeping and several hold secrets. Every table is replaced on each run because Keycloak records no row-level modification time -- emails, names and enablement all change in place -- and reconciling missed updates would cost more than a full re-read of a small realm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 35074009 | Triggered | Generic Password | f60407a | src/ol_dlt/tests/test_vault.py | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
There was a problem hiding this comment.
Pull request overview
Adds a reusable relational-database ingestion abstraction to ol_dlt, and introduces Keycloak Postgres as the first concrete database-backed source, including Vault-issued credential handling and Dagster scheduling.
Changes:
- Added
ol_dlt.databaseto build@dlt.sourceobjects from a declarativeDatabaseSourceSpec(with raw-table naming and optional incremental configuration). - Added
ol_dlt.vault(purehvac) to fetch and cache short-lived DB credentials and inject them at SQLAlchemy connection time. - Added Keycloak database source + Dagster asset wrapper and a daily schedule, with tests asserting table/column exclusions and deferred Vault access.
Reviewed changes
Copilot reviewed 11 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/ol_dlt/ol_dlt/database.py |
New reusable database source builder using dlt sql_table, deferred reflection, and per-connection credential injection. |
src/ol_dlt/ol_dlt/vault.py |
New Vault credential resolver (hvac) with lease-aware caching and clear error messaging for missing/denied paths. |
src/ol_dlt/ol_dlt/sources/keycloak/__init__.py |
Declares KEYCLOAK_SPEC and exposes a uniform build_source() entrypoint for Dagster. |
src/ol_dlt/ol_dlt/sources/keycloak/__main__.py |
Adds a standalone smoke-run entrypoint for local-dev execution. |
src/ol_dlt/tests/test_database.py |
Adds unit/integration coverage for spec validation, URL construction, Vault injection timing, and column exclusions. |
src/ol_dlt/tests/test_vault.py |
Adds tests for Vault address derivation, caching behavior, and error handling. |
src/ol_dlt/tests/sources/test_keycloak.py |
Adds regression tests for forbidden-table and secret-column exclusions plus raw naming. |
src/ol_dlt/pyproject.toml |
Adds dependencies/extras needed for SQL database ingestion and Vault (dlt sql_database, psycopg2-binary, hvac). |
src/ol_dlt/uv.lock |
Locks new dependencies/extras for ol_dlt. |
src/ol_dlt/README.md |
Documents how to declare and run database sources and what infra changes are required for deployments. |
dg_projects/data_loading/data_loading/defs/ingestion/assets.py |
Registers Keycloak ingestion assets in the Dagster data_loading code location. |
dg_projects/data_loading/data_loading/defs/ingestion/schedules.py |
Adds a daily schedule targeting the keycloak asset group. |
dg_projects/data_loading/uv.lock |
Updates the code location lockfile to include the updated ol_dlt dependency set. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… var Everything else in build_table_resource keys off the resolved profile, so leaving the table-format hint to fall back to DLT_PROFILE meant a targeted run or test could be configured for a deployed destination while hinting plain parquet -- or the reverse. active_table_format now takes an optional profile, matching dataset_name and bucket_root which already do. Also hoist the selection set out of the comprehension so it is built once rather than per candidate table. Both raised by Copilot on PR #2472. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Organizations are how Keycloak represents the institutional affiliations that downstream models need to attribute a learner to, so leaving them out would have meant resolving people without resolving who they belong to. Membership needs no new join table: Keycloak models it as membership of the organization's backing group (org.group_id -> keycloak_group.id), tagged by user_group_membership.membership_type, and both of those were already in scope. Only the organization itself and its email domains were missing. Schema verified against Keycloak 26.7.0's Liquibase changelogs rather than assumed -- org and org_domain are created in jpa-changelog-25.0.0, and org_domain's primary key is the composite (id, name), not a single id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two findings from Sentry's review of PR #2472. The credential cache's purpose is that one lease serves a whole run, but the check-then-fetch was unsynchronized. Nothing races for it today -- extraction runs with workers = 1 -- except that setting lives in .dlt/config.toml for an entirely unrelated reason (a dlt injectable-context bug), so raising it would have silently turned one lease per run into one per worker. A lock makes the cache correct on its own terms rather than by coincidence. The response parsing assumed a database secrets engine's shape. Pointing vault_mount at a KV-v2 path -- which nests its payload under data.data -- is a plausible enough misconfiguration that it deserves the same named error the denied and not-found cases already get, instead of a bare KeyError. The concurrency test needed a delay in the fake client to be worth anything: without one the fake read returns before a second thread is ever scheduled inside the window, and the test passed with the lock removed. It now fails without the lock and passes with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What are the relevant tickets?
N/A
Description (What does it do?)
Adds a reusable relational-database source to
ol_dlt, and uses the Keycloak Postgres database as its first instance. Keycloak is a good first case because it is net-new — it has no Airbyte connection and therefore no existing state or cursor to seed, so nothing has to be cut over.The reusable part —
src/ol_dlt/ol_dlt/database.pyA source is a declaration, not a pipeline. A
DatabaseSourceSpecofDatabaseTableentries becomes a@dlt.sourcebuilt on dlt'ssql_table, with resources renamed to the warehouse's raw-table convention (raw__<system>__app__postgres__<table>) so the existingRawDataDltTranslatorproduces correct asset keys and groups with no changes:Credentials —
src/ol_dlt/ol_dlt/vault.pyTwo constraints shaped this, and they are the part most worth reviewing:
build_source()runs at module scope inassets.py). A Vault lease taken there would be long stale — or revoked — by the time a scheduled run actually fires. So credentials are injected per connection, via a SQLAlchemydo_connectevent listener registered throughsql_table(engine_adapter_callback=...).defer_table_reflect=True) so no database connection is opened at import either. Resource names are set explicitly with.with_name(), so Dagster asset keys remain statically known despite the deferral.The connection URL for deployed profiles carries only a host and database name — a username or password never appears in it — and forces
sslmode=require(the RDS parameter group setsrds.force_ssl=1). Credentials come from<mount>/creds/readonlyand are cached for half the lease, so one lease serves every table in a run rather than one lease per table.vault.pyis purehvacand mirrors the environment contract ofauthenticate_vault(DAGSTER_VAULT_ROLE/DAGSTER_VAULT_MOUNT, Kubernetes auth in-cluster andVAULT_TOKENoutside it), becauseol_dltmust not import Dagster — enforced by the existing ruffTID251banned-api rule.Environment alignment
Which database gets read follows the deployment, not the spec.
DLT_PROFILE(derived fromDAGSTER_ENVIRONMENT) selects it:qareads the QA database,productionreads production, anddev/ci/testread the local-dev CloudNativePG cluster thatol-infrastructure/local-devstands up. A developer never needs access to a deployed environment to exercise a pipeline. Per-source overrides come from<NAME>_DB_HOST/_PORT/_USERNAME/_PASSWORD.Keycloak scope
A curated identity subset (12 tables: users, attributes, group membership, role assignment, realm/client context) rather than the whole schema. Keycloak's remaining ~80 tables are its own internal bookkeeping, and several hold credential material. Deliberately excluded, with a test asserting the exclusions hold:
credentialcomponent_config*_session,*_eventclient.secret,client.registration_tokenEvery table uses
write_disposition="replace". Keycloak records no reliable row-level modification time —user_entity.created_timestampis creation-only, while emails, names and enablement all change in place — so reconciling missed updates would cost more than a full re-read of a small realm. The spec supports incremental cursors (DatabaseTable.cursor_column, which requires a primary key to merge on) for future sources that do have one.Scheduled daily at 04:30 UTC, selecting by asset group rather than by key so adding a table to the spec does not also require editing the schedule.
How can this be tested?
Automated (from
src/ol_dlt, no credentials or network needed):69 tests pass. The ones that matter most for this change:
test_database.py::test_materializes_a_database_to_the_test_profile— end-to-end reflect → extract → write against a real relational source. SQLite stands in for Postgres so it stays hermetic; everything under test (deferred reflection, resource renaming, column exclusion, raw table layout) is driver-independent. It asserts an excluded column is absent from the written schema.test_database.py::test_deployed_profile_never_reads_vault_at_definition_time— replacesread_database_credentialswith a function that fails the test if called, then builds the production source. This is the regression guard for constraint (1) above.test_database.py::test_vault_credentials_are_injected_at_connection_time— fires thedo_connectevent and asserts the connect params carry the Vault-issued user and password.test_database.py::test_deployed_profile_url_omits_credentials_and_requires_tls— asserts the URL has no username or password and does setsslmode=require.test_vault.py— address derivation (devpoints at QA Vault, never production), lease caching and expiry, and that a denied path names the policy file to fix.test_keycloak.py— the forbidden-table and secret-column exclusions, and the raw naming convention.Also verified:
uv run ruff check --select TID251 .passes (no Dagster imported), anduv tree --package ol-dlt | grep -i dagsteris empty — the package/orchestration separation still holds.Dagster code location loads and resolves the assets:
Expect
12 keycloak assetsand30 4 * * *.Live run against local-dev (needs the ol-infrastructure local-dev stack up):
Note that a freshly bootstrapped local-dev
keycloakdatabase is empty until Keycloak has started against it and created its schema; deferred reflection means the failure in that case is a clear "no such table" rather than something subtle.pre-commitpasses on all changed files (ruff format, ruff check, mypy, detect-secrets).Additional Context
Requires a companion infrastructure PR before deployed runs work: mitodl/ol-infrastructure#5092 — it grants
postgres-keycloak/creds/readonlyin the Dagster Vault policy and passesKEYCLOAK_DB_HOSTthrough from the Keycloak stack export. Until that merges and deploys, a QA or production run of this pipeline fails immediately with a message naming exactly what is missing (no partial writes). Thedevprofile is unaffected and works today. Each future database source needs the same two additions there, and the README documents that.Two things intentionally left out of scope, worth deciding before the first production run:
ol_warehouse_raw_datasource YAML or staging model for these tables. That is a separate change once the raw tables actually exist and their shape is known.user_entityanduser_attributecarry email addresses and names into the raw layer. This PR applies no masking or access restriction beyond keeping credential material out entirely. If the raw layer needs column-level handling for those, it should be settled before this runs against production rather than retrofitted after data has landed.On CDC, which the migration notes flag as the eventual question: this source is cursor/replace-based and read-replica-safe by design. It captures no deletes. For Keycloak that is unlikely to matter much (user deletion is rare and
enabledis the operative flag), but it is a real gap for any future source where deletes are meaningful, and periodic reconciliation anti-joins are the cheaper first answer there.