Federating a Postgres Table into Redshift Guide
This guide is the step-by-step procedure for exposing a table from an application's Postgres database in our Redshift warehouse, and therefore in Metabase, using the federated-mirror pattern. It is the operational companion to the decision records that explain why the pattern exists: see ADR 0005 and ADR 0017.
Use it when you want analysts to be able to query an app table (e.g. an AdGem, AdSuite, or Offer-API table) in Metabase or in downstream dbt models.
TL;DR
Set the grants up once per source and you rarely touch them again. Do that and adding a table is three code-only steps. Skip it and every new table also needs two easily-forgotten manual grants, on opposite sides of the federation boundary, each of which fails in a confusing rather than obvious way.
| # | Step | Repo | Runs as | Needed when |
|---|---|---|---|---|
| 0 | One-time grant setup for the source | source Postgres + data-warehouse | source DB owner / admin | once, ideally at federation time |
| 1 | Grant the federation read user SELECT on the source table | source app's Postgres | app DB owner | only if Step 0's source half is missing |
| 2 | Create the materialized view in <source>_mirror | airflow-dags (*_raw) | airbyte | always |
| 3 | Re-grant reader/transformer on the mirror schema | data-warehouse | admin | only if Step 0's Redshift half is missing |
| 4 | Add the dbt model and wire the refresh DAG | airflow-dags | n/a | always |
| 5 | Deploy, then run a Metabase schema sync | n/a | n/a | always |
No source has Step 0 configured yet. For AdSuite, AdGem, and Offer-API today you still need Steps 1 and 3 on every new table. Doing Step 0 for a source is the highest-leverage change available here, and it is what would have prevented CAM-1207 outright.
If the source database has never been federated before (no
<source>_prod external schema exists yet), you have a larger first-time setup
in front of you. See First-time source setup below.
How the pattern works
A source app DB is exposed to Redshift through a federated external schema
(<source>_prod, e.g. adgem_prod, adsuite_prod, offer_api_prod). We do
not let analysts query that live, because every query would hit the production
database. Instead we materialize a copy into a mirror schema
(<source>_mirror) in the adgem_raw Redshift database, refresh it on a
schedule, and a dbt model copies it into the analyst-facing adgem_analysis
schema that Metabase reads.
There are three copies of the data (production table, materialized view, analysis table). That is intentional and matches the established pattern -- see ADR 0017 for the rationale (minimize load on the source DB, give dbt something it can read and mock, and keep analysts out of the raw database).
Two repos own different layers
This split is the single most important thing to internalize, because the two forgettable grants live on opposite sides of it. The full breakdown is in the Liquibase Usage Guide; the short version:
data-warehouseowns the warehouse's structure: the external schema, the mirror schema, and the role grants. Migrations here run asadmin.airflow-dagsowns the data objects: the materialized views, the dbt models, and the refresh DAG. Migrations here run asairbyte(*_raw) orairflow(*_analysis).
Set this up once per source
The per-table grants exist for exactly one reason: the grants we use today are
point-in-time. GRANT ... ON ALL TABLES IN SCHEMA covers the tables that exist
when it runs and nothing added afterwards. Configure each side of the boundary
once and Steps 1 and 3 disappear for every future table on that source.
Do this for any new source, and it is worth backfilling onto existing ones.
Source Postgres -- default privileges for the migration role
ALTER DEFAULT PRIVILEGES
FOR ROLE <role_that_creates_the_app_tables>
IN SCHEMA public
GRANT SELECT ON TABLES TO warehouse_readonly;
FOR ROLE is the part that matters. Postgres applies default privileges
only to objects created by the role named in FOR ROLE, defaulting to the role
that runs the statement. Run this as an admin and omit the clause and it
silently covers only that admin's own future tables, never the ones the app's
migrations create. It then looks configured and still fails on the next table.
Name the role that actually runs the app's migrations.
This half sits on the app's RDS, outside our repos, so it is applied by hand (see the Tinker path in Step 1). Micah owns the Postgres side.
Redshift -- scoped permissions on the mirror schema
GRANT SELECT FOR TABLES IN SCHEMA <source>_mirror TO ROLE reader;
GRANT SELECT FOR TABLES IN SCHEMA <source>_mirror TO ROLE transformer;
Note FOR TABLES IN, not ON ALL TABLES IN. That is Redshift's
scoped permissions
form, and it applies to all current and future objects in the schema.
Prefer it over ALTER DEFAULT PRIVILEGES on the Redshift side. Redshift's
default privileges are creator-scoped the same way Postgres's are -- they apply
to "objects that are created in the future by the specified user" -- and the
mirror MVs are created by airbyte while this migration runs as admin. A
default-privileges statement would therefore have to name FOR USER airbyte to
have any effect at all. Scoped permissions are creator-agnostic, which removes
that failure mode instead of documenting around it.
Add it as a changeset in data-warehouse migrations/changelog-adgem_raw.yaml,
alongside the existing mirror-schema grants. USAGE ON SCHEMA is still a
separate, one-time grant.
Keep running Step 3 until you have seen this work once on a real
source. AWS does not state whether materialized views count as tables for scoped
FOR TABLES, and every mirror object is an MV, so confirm the first new table
picked up reader without a re-grant:
SELECT relation_name, identity_name, privilege_type
FROM svv_relation_privileges
WHERE namespace_name = '<source>_mirror';
A redundant re-grant costs nothing; a missing one is a failed build.
The two per-table grants (when Step 0 is missing)
Without the one-time setup above, a mirrored table needs two separate
SELECT grants, on opposite sides of the federation boundary. Both are easy to
miss because a point-in-time GRANT ... ON ALL TABLES IN SCHEMA only covers
tables that existed when it ran -- it does not auto-cover tables added later.
| # | Grant | Where it lives | Skip it and... |
|---|---|---|---|
| 1 | GRANT SELECT ON <table> TO <federation_read_user> (e.g. warehouse_readonly) | the source app's Postgres (not in any repo today) | CREATE MATERIALIZED VIEW fails on deploy with permission denied for table <table>. The refresh DAG then fails every cycle because the MV was never created. |
| 2 | GRANT SELECT ON ALL TABLES IN SCHEMA <source>_mirror TO ROLE reader (and transformer) | data-warehouse changelog-adgem_raw.yaml | the dbt build fails with Relation <table> does not exist. Cross-database, Redshift hides an ungranted relation rather than reporting "permission denied." |
The trap: copying an existing mirror entry looks like a code-only change, but it silently depends on both grants. Grant 1 is not codified anywhere, and Grant 2 must be re-run for the new table. This is exactly what turned CAM-1207 into a multi-day incident.
Steps
Step 1 (source) -- grant the federation read user SELECT
Skip this if the source has the one-time default privileges in place. No source does today.
Run this against the source app's Postgres, as a role that can grant on the table (the app's DB owner):
GRANT SELECT ON public.<table> TO <federation_read_user>;
The federation read user is whatever role backs the <source>_prod external
schema's SECRET_ARN (for AdSuite/AdGem/Offer-API this is warehouse_readonly).
If you do not have direct RDS / Secrets Manager access, use Laravel Tinker over AWS SSM Session Manager -- the app instance can reach its own DB:
# SSM into the app instance, then:
cd /var/app/current
php artisan tinker
\DB::statement("GRANT SELECT ON public.<table> TO <federation_read_user>");
This is the same path Micah documented for creating a read-access role:
Creating a Read Access Role in a Postgres Database Using Tinker.
For an existing federation user you only need the GRANT above; the full
runbook (including the password_encryption = 'md5' step Redshift federation
requires) only applies when creating a new federation user.
Step 2 (airflow-dags) -- create the materialized view
Add a changeset to migrations/changelog-adgem_raw.yaml:
- changeSet:
id: <next>
author: <you>
labels: materialized_view
comment: >
create <source>_mirror.<table> materialized view (TICKET-123).
Columns are listed explicitly: an MV freezes its column list at creation,
so adding a column in the source later requires a recreate changeset.
changes:
- sql:
sql: >
CREATE MATERIALIZED VIEW <source>_mirror.<table> AS
SELECT <col1>, <col2>, created_at, updated_at
FROM <source>_prod.<table>
rollback:
- sql:
sql: DROP MATERIALIZED VIEW IF EXISTS <source>_mirror.<table>
List columns explicitly. It makes the contract visible and signals that a
future source column-add needs a recreate changeset. (You can SELECT * to
match older entries, but it does not save you anything: an MV freezes its
column list at creation either way.)
Step 3 (data-warehouse) -- re-grant the roles on the mirror schema
Skip this if the mirror schema has scoped permissions in place and you have confirmed they cover MVs. No mirror schema does today.
Add a changeset to migrations/changelog-adgem_raw.yaml in data-warehouse.
The grant is idempotent and re-running it picks up the new MV:
- changeSet:
id: <next>
author: <you>
labels: permissions
comment: re-grant reader and transformer on <source>_mirror after adding <table> (TICKET-123)
changes:
- sql:
sql: GRANT USAGE ON SCHEMA <source>_mirror TO ROLE reader
- sql:
sql: GRANT SELECT ON ALL TABLES IN SCHEMA <source>_mirror TO ROLE reader
- sql:
sql: GRANT USAGE ON SCHEMA <source>_mirror TO ROLE transformer
- sql:
sql: GRANT SELECT ON ALL TABLES IN SCHEMA <source>_mirror TO ROLE transformer
dbt reads the mirror via the reader role. Grant transformer as well to stay
consistent with the existing mirror schemas.
Step 4 (airflow-dags) -- add the dbt model and wire the DAG
-
Model:
dbt/<project>/models/<schema>/<table>.sql:{{ config(schema='<schema>', materialized='table') }}SELECT * FROM {{ source('<source>_mirror', '<table>') }} -
Source: declare the mirror table in
source.yml. -
Docs: document the model in
schema.yml(description,meta.owners, tags) so Metabase and DataHub pick up descriptions. -
DAG: add the table to the refresh DAG's
tables = [...]list and to the dbt--selectcommand. Example fromadsuite_to_ag_dag.py:tables = ["iterations", "strategies", "apps", "app_kpi_targets"]connector.refresh_materialized_views("adsuite_mirror", tables)# ...command="run --select iterations --select strategies --select adsuite_apps --select app_kpi_targets", -
Watch for dbt model-name collisions. Model resource names are globally unique across the dbt project, so if the obvious name is taken (e.g.
appsalready exists as AdGem's catalog model) the file cannot just be<table>.sql. Either way the file is named<source>_<table>.sql; the choice is what the physical table ends up called. Two options, and they trade off differently.Option A: alias back to the clean name. Keep the schema as the disambiguator, so the physical table is
<schema>.<table>.{{ config(schema='<schema>', alias='<table>', materialized='table') }}SELECT * FROM {{ source('<source>_mirror', '<table>') }}Aliasing is safe: the dbt-to-Metabase doc sync keys on the physical relation (the alias), not the resource name, so descriptions still attach. This is what the existing models do. See
adsuite_apps-- resourceadsuite_apps,alias='apps', materialized as the cleanadsuite.apps, alongsideadsuite_campaignsandadsuite_users.Option B: rename the table to something unambiguous. Drop the alias and give the physical table a distinct name that says what the entity is.
{{ config(schema='<schema>', alias='advertiser_apps', materialized='table') }}SELECT * FROM {{ source('<source>_mirror', 'apps') }}The case for this: schema-level disambiguation only helps someone who is reading the schema. AdGem
appsis a publisher entity and AdSuiteappsis an advertiser entity, and in Metabase's table picker both surface as "Apps" with nothing to tell them apart. A distinct physical name is self-describing wherever it appears. If you take this option, document both tables in DataHub so the distinction is written down somewhere other than the name.Option A keeps the naming consistent with what is already built; Option B is clearer at the point of discovery. If there is no collision at all, skip the alias and name the file
<table>directly.
Step 5 -- deploy, then refresh Metabase
Both repos auto-deploy their Liquibase on merge to main (when the changelog
changed). Order matters: deploy the airflow-dags MV (Step 2) first, then
the data-warehouse grant (Step 3). The grant is a point-in-time
GRANT ... ON ALL TABLES IN SCHEMA, so it only covers the new MV if the MV already
exists when it runs -- deploy it before the MV and it silently misses the new table,
and Liquibase will not re-run the (already-applied) changeset. Between the two
deploys there is a short window where the refresh DAG's dbt step cannot read the new
MV; it self-heals once the grant lands, or pause the DAG across the two deploys.
(Note this is the opposite of Grant 1, the source grant, which must be in place
before the MV deploys. This whole ordering constraint is an artifact of the
per-table grant: with scoped permissions
on the mirror schema there is no Step 3 to order and no gap to bridge.) After the
DAG builds the analysis table, run a Metabase schema sync on adgem_analysis so
the new table appears in the query builder; the daily dbt-to-Metabase doc sync then
attaches descriptions.
Verify
- Source grant landed -- the
CREATE MATERIALIZED VIEWdeploy succeeded (nopermission denied), and the DAG'srefresh_materialized_viewstask is green. - Mirror MV is readable by
reader(Metabase database "Adgem Raw"):The new table should appear withSELECT relation_name, identity_name, privilege_typeFROM svv_relation_privilegesWHERE namespace_name = '<source>_mirror';reader/SELECTalongside the others. - Built and populated (Metabase database "Adgem Analysis"):
SELECT count(*) FROM <schema>.<table>;
- DAG run is green end to end (both
refresh_materialized_viewsand the dbt task).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Deploy fails: permission denied for table <table> on CREATE MATERIALIZED VIEW | Grant 1 (source) missing | Run Step 1, then re-run the failed deploy so the changeset applies. |
dbt build fails: Relation <table> does not exist (and the relation clearly exists) | Grant 2 (reader on <source>_mirror) missing | Run Step 3. Cross-database, Redshift reports a missing grant as "does not exist." |
The whole adgem_raw Liquibase pipeline is stuck, blocking unrelated migrations | An earlier changeset failed (usually Grant 1) and retries every deploy | Fix the underlying grant, then re-deploy. A never-applied changeset can be deleted (no databasechangelog record to roll back); an applied one must be reversed with a new changeset. |
refresh_materialized_views fails: Materialized view <table> not found | The MV create failed earlier (see Grant 1) so the MV does not exist | Fix Grant 1 and let the create changeset apply before the next refresh. |
Refresh fails after a source schema change: column ... does not exist | The source table's columns changed; the MV's column list is frozen at creation | Add a recreate changeset (DROP + CREATE) in airflow-dags, then re-grant in data-warehouse (see CAM-1043). |
| Table is built but not visible in Metabase | Metabase has not synced its schema | Run a Metabase schema sync on adgem_analysis. |
Gotchas
- Two grants, two repos. The number-one cause of "I followed the pattern
but it doesn't work." Source grant on the app's RDS; role grant in
data-warehouse. The durable fix is the one-time setup, not remembering harder. - Point-in-time grants.
GRANT ... ON ALL TABLES IN SCHEMAdoes not cover future tables, so without Step 0 each new table needs its own re-grant. This is the root cause of every grant-related failure in this guide. - Creator-scoped default privileges. If you do reach for
ALTER DEFAULT PRIVILEGESon either side, it only affects objects created by the role it names (the current role if unnamed). Getting that wrong is worse than not doing it, because the setup looks complete and still fails. See Step 0. - Cross-database "does not exist". When dbt reads
adgem_raw.<source>_mirror.<table>fromadgem_analysis, a missing grant surfaces as "relation does not exist," not "permission denied." - MV column list is frozen at creation. A source column add/drop/resize
needs a
DROP+CREATErecreate changeset, not just a refresh. mv_tbl__faux tables. Redshift backs each MV with a hiddenmv_tbl__-prefixed table. Never query those directly; query the analysis table inadgem_analysis.
First-time source setup
Everything above assumes the source already has a <source>_prod external
schema. Bringing a brand-new source database into the warehouse is a larger,
mostly one-time job and touches CDK and Secrets Manager:
- Read-only DB user on the source Postgres (
warehouse_readonlyor equivalent), withpassword_encryption = 'md5'(Redshift federation requires MD5). See Micah's Tinker read-access runbook. - Secret + IAM (
data-warehouseCDK): aprod/<Source>DB/warehouse_readonlysecret, plus the source RDS KMS key and the secret ARN on the warehouse IAM policy. - External schema (
data-warehouseLiquibase):CREATE EXTERNAL SCHEMA IF NOT EXISTS <source>_prod FROM POSTGRES DATABASE '<db>' SCHEMA 'public' IAM_ROLE default SECRET_ARN '<arn>' URI '<rds-endpoint>'. - Mirror schema (
data-warehouseLiquibase):CREATE SCHEMA IF NOT EXISTS <source>_mirrorplus thereader/transformergrants. - The one-time grant setup -- do Step 0 here, while you are already in both sides. This is the cheapest moment to do it and the reason the per-table grants keep biting us is that it was skipped on every existing source.
After that, adding each table is Steps 2, 4, and 5 above.
Worked examples
- CAM-1207 -- mirrored AdSuite
apps+app_kpi_targets. Both grants were missing and were discovered one outage at a time; the canonical cautionary tale for this guide. - CAM-1043 -- recreate
prod_mirror.appsafter a source column was dropped (the frozen-column-list gotcha). - AGPI-1637 / AGPI-1742 -- straightforward additions (
failed_webhooks,offer_metrics) that show the happy-path shape of the change.
Related ADRs and guides
- Liquibase Usage Guide -- which repo a migration belongs in and which warehouse user it runs as.
- ADR 0005: Federated Queries in Redshift -- why we use federated external schemas.
- ADR 0017: Federated Table Materialized Views -- why we copy MVs into dbt models.
- ADR 0032: Redshift Permissions Management Tooling -- how roles and grants are modeled.