refactor(backend): the two contract schemas the tracks meet through - #5
refactor(backend): the two contract schemas the tracks meet through#5lassebenni wants to merge 4 commits into
Conversation
The data team publishes marts into an analytics schema next to the application tables, and reads application rows back through a separate read-only role. Both roles and the schema belong in the backend repo as a migration, so the setup is reproducible rather than a console command someone ran once. ALTER DEFAULT PRIVILEGES names the writer role. Without FOR ROLE the grant only covers tables created by whoever ran the migration, and the sync creates a new table on every publish, so the application would be locked out of its own marts. Reproduced against PG16 and verified fixed.
| -- flyway.placeholders.app_role= the role the backend logs in as | ||
| -- flyway.placeholders.analytics_writer_password= | ||
| -- flyway.placeholders.analytics_reader_password= |
There was a problem hiding this comment.
Can you also add those value to the config file? Together with an env variables
| -- flyway.placeholders.analytics_writer_password= | ||
| -- flyway.placeholders.analytics_reader_password= | ||
|
|
||
| CREATE SCHEMA IF NOT EXISTS analytics; |
There was a problem hiding this comment.
That's an interesting schema name analytics - doesn't sound very "data engineering" related to me. Is this a standard name?
| CREATE ROLE analytics_writer LOGIN PASSWORD '${analytics_writer_password}'; | ||
| END IF; | ||
| IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'analytics_reader') THEN | ||
| CREATE ROLE analytics_reader LOGIN PASSWORD '${analytics_reader_password}'; | ||
| END IF; | ||
| END | ||
| $$; |
There was a problem hiding this comment.
Do we need the analytics reader role? Because the app_role already has a read only access to the analytics
There was a problem hiding this comment.
Pull request overview
Adds a Flyway migration to establish a consistent database security boundary between the backend application and the analytics/data pipeline by creating an analytics schema, dedicated roles, and grants/default-privileges.
Changes:
- Introduces
analyticsschema plusanalytics_writer/analytics_readerroles. - Grants read-only access for the app role to analytics marts, and sets default privileges so newly published marts remain readable.
- Grants read access for the analytics reader role to current and future application tables in
public.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| -- | ||
| -- Three placeholders. Set them in your Flyway configuration and take the | ||
| -- values from Key Vault. Never put a password in this file: it is committed. | ||
| -- flyway.placeholders.app_role= the role the backend logs in as |
There was a problem hiding this comment.
Maybe it's a good idea to create the app_role in this script as well. and replace this with app_role_password.
Otherwise, we either set admin as the app_role or manually create the role
…ation Reworks the boundary after review. Three changes, each verified against the live PostgreSQL 16 server rather than reasoned about. The pipeline no longer reads public. It reads an app schema of views this application chooses to expose, mirroring the analytics schema it publishes into. Each side owns the schema it writes and neither reads the other's internals, so renaming a column here cannot break their 6am run. It also moves the hashing of personal data into our view, where it belongs: the raw user id never leaves this database rather than being discarded by someone else afterwards. analytics_reader is renamed app_reader, because it never read analytics. It reads the application's data, and the old name invited exactly the question it got in review. The password placeholders are gone. Passing a password through a placeholder only moves the problem: it lands inside a single-quoted SQL literal, so a rotated password containing a quote breaks the migration in a way that is hard to diagnose. Roles are created able to log in but unable to authenticate until a password is set out of band, which is what the provisioning script does when it writes them to Key Vault. One placeholder remains, app_role. Schemas now declare an owner. Without AUTHORIZATION the schema belongs to whoever ran the migration, and if that is the application's own login then the application can drop the marts it is only meant to read. The app schema needed two ALTER DEFAULT PRIVILEGES rather than one, which the test found by failing: a view created by a migration belongs to the migration runner, not to app_role, so naming only app_role left it invisible to the sync as a permission error on a view plainly sitting there. Verified end to end on a scratch database: writer publishes, the app reads the mart without ever granting itself anything, the app cannot delete it, the pipeline reads the view and sees only a hash, and the pipeline is refused on public. Scratch database and roles dropped afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without it the migration cannot run at all. Flyway refuses an unresolved
placeholder, so the application context fails to start and every test fails
with 'No value provided for placeholder: ${app_role}'. The previous version
left this to whoever deployed it, which meant the build was red for everyone.
The default is CURRENT_USER, the role running the migration. That is what makes
it work with no configuration in tests and in local development, where the
application and the migration are the same login. Verified that PostgreSQL
accepts CURRENT_USER as a role specification in all four places the migration
uses it: GRANT, ALTER DEFAULT PRIVILEGES FOR ROLE, and CREATE SCHEMA
AUTHORIZATION.
Set APP_ROLE wherever the two differ, so the grants land on the role that
serves requests rather than the one that happened to run the migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s-schema # Conflicts: # backend/src/main/resources/application.yaml
|
Closing the PR in favor of a DB setup script outside of flyway |
What I built
V2__analytics_schema.sql: the two schemas the backend and the data pipeline meet through, the roles that cross them, and the grants that keep each side out of the other's internals.Why this approach
Two contract schemas, each owned by the side that writes it.
publicappapp_readeranalyticsanalytics_writer${app_role}The earlier version let the pipeline select straight from
public. That coupled their nightly run to our internal schema: renaming a column here would break their 6am job, and nobody would notice until the numbers went stale. A view is a promise, and changing one is a visible act.It also moves the hashing of personal data to where it belongs. Previously the pipeline read raw user ids and discarded them afterwards. Now the view hashes, so the raw value never leaves this database at all. That is a better answer to who is responsible for it.
analytics_readeris nowapp_reader, because it never readanalytics. It reads this application's data.No passwords in the migration. A password in a migration is a password in git, and passing one as a placeholder only moves where it is written down: it lands inside a single-quoted SQL literal, so a rotated password containing a quote breaks the migration in a way that is hard to diagnose. The roles are created able to log in but unable to authenticate until a password is set out of band, which is what the provisioning script does when it writes them to Key Vault. One placeholder remains,
${app_role}.Contract impact
This is the contract. Nothing existing changes:
publicis untouched and no application table is altered. The pipeline side is in #9.How to run
Needs CREATEROLE, so it runs as the Entra administrator rather than
hyfadmin. See the header.Self-check
Verified, not assumed
Against the live PostgreSQL 16 server, on a scratch database, dropped afterwards:
analytics_writerpublishes a mart${app_role}reads it, having granted itself nothing${app_role}tries to delete itpermission denied for table fct_postingsapp_readerreadsapp.saved_jobsuser_hash=a1d0c6e8…, raw id never exposedapp_readertriespublic.saved_jobspermission denied for table saved_jobsWhat the review found
Two things the tests only found by failing:
appschema needed twoALTER DEFAULT PRIVILEGES, not one. A view created by a later migration belongs to the migration runner, while a view created by the running application belongs to${app_role}. Default privileges cover one creator each, so naming only${app_role}left migration-created views invisible to the sync, as a permission error on a view plainly sitting there.hyfadminhas neither CREATEROLE nor CREATEDB on this server.ERROR: permission denied to create role. The Entra administrator is the identity that has them, and the header now says so.Copilot's five comments are addressed and resolved. Stas' four are still open and unanswered, including the one about whether this migration should create
app_roleitself, which is a decision about the application's own login rather than about this boundary.🤖 Generated with Claude Code