fix(db): add primary key to group_permissions to prevent duplicate grants #469
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
public.group_permissionsis a join table mappingUserGroup → permissionstrings. Pre-prod audit found the table has no primary key and no UNIQUE constraint — only an FK back touser_groups:This means the table can hold duplicate
(group_id, permission)rows. The duplicates are harmless at the resolved-permissions level (the application usesSet<String>semantics), but:MERGE-style sync code will not be idempotent.transcription_block_mentioned_personshas the same problem on paper but has a realUNIQUE(block_id, person_id), which acts as a de-facto PK; declaring it explicitly is hygiene only.Approach
Two-step Flyway migration:
V61__deduplicate_group_permissions.sql
V62__group_permissions_primary_key.sql
Companion: declare the de-facto PK on the other table
(Or skip the rename — UNIQUE behaves the same operationally; the only reason to bother is consistency with our naming convention.)
Critical files
backend/src/main/resources/db/migration/V61__deduplicate_group_permissions.sql— newbackend/src/main/resources/db/migration/V62__group_permissions_primary_key.sql— newbackend/src/main/resources/db/migration/V63__tbmp_promote_unique_to_pk.sql— new (optional, hygiene)UserGroup.permissions— likely@ElementCollection. After the PK is in place, the existing mapping should still work, buthibernate-toolsorddl-auto: validatewould catch a mismatch.Verification
Pre-migration check on real data:
Document the output for the migration's smoke trace. Audit's local DB had no duplicates, but production may have accumulated some.
Migration test: V61 + V62 + V63 run cleanly against a fresh DB and against a fixture DB pre-seeded with duplicates.
JPA validate:
spring.jpa.hibernate.ddl-auto: validate(transient setting for the test) shows the mapping matches.Smoke: open
/admin/groups/<id>, edit permissions; confirm save works and no duplicate rows appear.Acceptance criteria
group_permissions(no(group_id, permission)pair appears more than once).PRIMARY KEY (group_id, permission).transcription_block_mentioned_personsto a PK.INSERT ... ON CONFLICT DO NOTHING(or its JPA equivalent) becomes safe to use in admin code.Effort
XS — 1 hour including the dedup query review.
Risk if not addressed
Phantom row accumulation; non-idempotent permission sync; harder admin diagnostics.
Tracked in audit doc as F-32 (Medium) — new dynamic finding from schema constraint inspection.
🏛️ Markus Keller (@mkeller) — Application Architect
Observations
V1__initial_schema.sqlcreatesgroup_permissionswith only an FK (fkaqmvqpbaqnfeg5ixk88k8u6i9) and no PK or UNIQUE constraint. This is a schema omission from day one, not a regression — the table has always been structurally incomplete.UserGroupmaps@ElementCollectionwith@CollectionTableand aSet<String> permissions. Hibernate's@ElementCollectionusesDELETE + INSERTper update (not an upsert), so duplicate rows can only accumulate from raw SQL operations like migrations — e.g., V11 (add_annotate_all_permission) and V59 (seed_blog_write) bothINSERT INTO group_permissionsdirectly. V59 already guards against duplicates withAND NOT EXISTS (...), which is the right instinct but works only because the dedup logic is present; the constraint would make it redundant.transcription_block_mentioned_personssituation. V57 addeduq_tbmp_block_person UNIQUE (block_id, person_id). A UNIQUE constraint in PostgreSQL is backed by a B-tree index and behaves as a de-facto PK. Promoting it to a named PK (pk_tbmp) is naming hygiene only — operationally identical. The issue is correct that this is optional.db-orm.puml,db-relationships.puml) does not need updating — the table already exists in the diagram; the PK declaration is internal constraint metadata, not a structural change.Recommendations
RenameUsersToAppUsersMigrationTestthat asserts: (a) no(group_id, permission)pair appears more than once, and (b) a PK namedpk_group_permissionsexists ongroup_permissions. The existing migration test harness (Testcontainers +@DataJpaTest) makes this straightforward — it runs all migrations on a real PostgreSQL container on every CI run.ctidcomparison which is PostgreSQL-specific; verify it behaves correctly when there are 0 duplicates (no-op), 1 duplicate, and N>2 duplicates for the same(group_id, permission).pk_*vsuq_*signals different semantics to anyone reading the schema. One migration, three lines. Do it.NOT NULLonpermissioncolumn. The initial schema haspermission character varying(255)with no NOT NULL constraint. A NULL permission would be silently skipped by theSet<String>in Java but would pass the PK ifgroup_idis unique — a NULL permission in a PK is PostgreSQL-valid but semantically wrong. AddALTER TABLE group_permissions ALTER COLUMN permission SET NOT NULL;as part of V62 or a companion migration.👨💻 Felix Brandt (@felixbrandt) — Fullstack Developer
Observations
@ElementCollectionwithSet<String>means Hibernate always issues aDELETE FROM group_permissions WHERE group_id = ?followed byINSERTstatements whensetPermissions()is called. This is whatupdateGroup()inUserServicedoes. Once V62 adds the PK, these DELETEs + INSERTs remain correct because the PK only blocks duplicate rows — it does not change the DELETE+INSERT lifecycle. No JPA code change is needed.createGroup()has a subtle gap. It callsnew UserGroup()+group.setName(...)+group.setPermissions(...)instead of using the@Builder. This bypasses@Builder.Default, meaningpermissionscould theoretically benullifdto.getPermissions()is null. In practiceGroupDTO.permissionsis aSet<String>that can be null (no@NotNull), andsetPermissions(null)on the entity would set the collection to null. After the PK exists, a null permission field would cause a constraint violation on INSERT — which is actually the right behavior but the error message would be confusing. Better to guard it now:group.setPermissions(dto.getPermissions() != null ? dto.getPermissions() : new HashSet<>()).updateGroup()has the same null risk.if (dto.getPermissions() != null) group.setPermissions(dto.getPermissions())— this is correct as a partial-update guard, butdto.getPermissions()could also be an empty set (meaning "remove all permissions"), which is a valid intent and currently works. Good.GroupControllerorUserService.createGroup/updateGroup.UserServiceTesttests user CRUD but has zero coverage of group management methods.GroupControllerhas no test file at all. The migration work is an ideal moment to add these.INSERT ... WHERE NOT EXISTSis correct but should become unnecessary. After V62, V59's guard is redundant for future migrations. Worth noting in the migration comment that the constraint replaces this pattern.Recommendations
@DataJpaTest+ Testcontainers, seed a group with duplicate(group_id, permission)rows viaJdbcTemplate.update(...), run V61 (or assert post-migration state), then assert zero duplicates remain. Then add V62 and assert the PK constraint exists viainformation_schema.table_constraints.UserServiceunit test forcreateGroupwith null permissions — this is a missing test, not just a migration concern. One test:given dto.permissions is null, createGroup returns a group with empty permissions. Write it red, then fix the null-guard increateGroup(), then go green.@NotNullor at least an empty-set guard toGroupDTO.permissionsto prevent null propagating into the DB layer. A@jakarta.validation.constraints.NotNullannotation onGroupDTO.permissionswould let Spring MVC reject a null payload before it reaches the service.createGroup()instead ofnew UserGroup()+ setters, for consistency with the rest of the codebase:🔒 Nora "NullX" Steiner — Security Engineer
Observations
/admin/groups/<id>UI readsgroup_permissionsvia JPA'sSet<String>, it de-duplicates automatically (Java Set semantics). But any raw SQL query against the table — audit queries, reporting, backup validation scripts — will see phantoms. An operator might conclude a group has twoWRITE_ALLgrants and mistakenly delete one, not realizing a second remains.updateGroup()sets new permissions, Hibernate issuesDELETE FROM group_permissions WHERE group_id = ?then re-inserts the desired set. This is safe and idempotent — the PK constraint does not change this path. Good.NOT EXISTSguard in V59 is a manual uniqueness check in SQL. After V62, future data migrations can safely useINSERT ... ON CONFLICT DO NOTHING— which is atomic and race-condition-free. This is an improvement to the security posture of future migrations.permission character varying(255)with no NOT NULL constraint. ANULLvalue in thepermissioncolumn would behave inconsistently: JPA'sSet<String>would include it as a null element (or silently skip it, depending on Hibernate's element collection handling), and the PostgreSQL PK would accept it (NULL != NULL in a unique constraint, but in a PK context NULLs are rejected). The net result is a NULL permission that could never be deleted via normal JPA flow. This is a latent integrity bug.PATCH /api/groups/{id}orPOST /api/groups.GroupControllercarries@RequirePermission(Permission.ADMIN_PERMISSION)at the class level, which is correct. But there is no@WebMvcTestthat asserts a403is returned when a user with onlyREAD_ALLattempts to modify a group. This is a missing security regression test.Recommendations
NOT NULLtopermissionin the migration. IncludeALTER TABLE group_permissions ALTER COLUMN permission SET NOT NULL;in V62 alongside the PK. A NULL permission has no valid meaning — eliminate it at the schema level.@WebMvcTestforGroupControllerthat explicitly tests403for non-ADMIN_PERMISSIONusers on all write endpoints (POST /api/groups,PATCH /api/groups/{id},DELETE /api/groups/{id}). The class-level annotation makes this easy — mock the service, call with a user having onlyREAD_ALL, assert403. This is a permanent security regression test.ON CONFLICT DO NOTHINGpattern in a comment in V62 itself, so future migration authors know the safe idempotent insert pattern:ddl-auto: validatepasses after V61+V62+V63. Spring's JPA validation mode will check that the database schema matches the Hibernate entity mappings. Runningspring.jpa.hibernate.ddl-auto=validatein a test profile is a cheap way to catch mapping drift introduced by the PK change.Open Decisions
permissioncolumn be constrained to knownPermissionenum values via a CHECK constraint? ACHECK (permission IN ('READ_ALL', 'WRITE_ALL', 'ADMIN', ...))would prevent garbage strings from entering the table. Benefit: catches bugs in migrations or admin tooling. Cost: every newPermissionenum value requires a companion migration to widen the CHECK. Given that adding permissions is rare and high-stakes, this is a meaningful tradeoff worth an explicit decision.🧪 Sara Holt (@saraholt) — QA Engineer
Observations
RenameUsersToAppUsersMigrationTest) using@DataJpaTest+ Testcontainers +JdbcTemplate. V61/V62/V63 have no corresponding test class. This means CI cannot currently verify that the dedup query works, that the PK was added, or that theNOT NULLconstraint (if added) is in place.ctid, which is PostgreSQL-specific and correct — but its correctness in edge cases (zero duplicates, one pair of duplicates, three copies of the same row) needs a test fixture to confirm. The issue itself notes "Audit's local DB had no duplicates, but production may have accumulated some." This is exactly the kind of scenario that integration tests with pre-seeded fixtures catch.seed_blog_write.sql) was shipped without a migration test confirming that the seed ran correctly and did not create duplicates. The V59NOT EXISTSguard is a code-review-visible safety mechanism, but there is no test asserting it. This is pre-existing test debt that V62 implicitly fixes — worth noting in the test.UserService.createGroupandupdateGrouphave no unit tests.UserServiceTestcovers user lifecycle but skips group management entirely. A permissions regression (e.g., a future refactor that accidentally passes a mutable list instead of a defensive copy tosetPermissions) would go undetected.Recommendations
GroupPermissionsMigrationTestalongside V61/V62. Structure:RenameUsersToAppUsersMigrationTest— same annotations, sameJdbcTemplatehelpers.@Sqlscript orJdbcTemplatein a@BeforeEachto insert a group with two identical(group_id, 'READ_ALL')rows before asserting that V61 reduced them to one.UserServiceunit tests forcreateGroupandupdateGroup— at minimum: creating a group with a non-null permission set, creating a group with a null permission set, updating permissions replaces rather than appends, and updating with null permissions is a no-op./admin/groups/<id>, edit permissions, confirm save works") should become a Playwright E2E test step rather than manual verification. A single E2E test that saves a group with a new permission and then re-reads the group to confirm no duplicates in the API response would make this AC permanently automated.Open Decisions
JdbcTemplate, then apply V61+V62 and assert. Flyway'stargetproperty supports this. Worth deciding before implementing the test.⚙️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Observations
@DataJpaTest+PostgresContainerConfigwithpostgres:16-alpine. Every Flyway migration runs in CI on each PR. If V61 or V62 are broken SQL, CI will catch it before it reaches the production database. This is the correct safety net.ctidedge case), Flyway marks V62 as failed and stops. No partial state. The application continues to start with V61 applied but V62 pending — this is safe since V61 alone does not break anything.Recommendations
postgres:16-alpine. If production is running a different minor version (e.g., 16.3 vs 16.1),ctid-based deduplication is unaffected —ctidbehavior has not changed across PostgreSQL 16 minor releases. Still worth confirming the exact production version (SELECT version();) before the deploy.docker-compose.ymlchange needed. This is a schema-only fix. No new service, no new volume, no env var. The migration runs as part of the normal Spring Boot startup sequence.Open Decisions
📋 Elicit — Requirements Engineer
Observations
psqlconstraint query output), impact analysis (three distinct problem classes), a two-step migration approach, companion SQL, and five acceptance criteria that are directly testable. This is definition-of-ready for implementation.@ElementCollectiondoes not useON CONFLICTsyntax — it usesDELETE + INSERT. This AC describes a future-safety guarantee for raw SQL migrations, not a change to application code. The wording should clarify the intended scope to avoid a developer checking whether JPA was changed to use ON CONFLICT.transcription_block_mentioned_personsto a PK. The issue describes it as "hygiene only" and marks it optional. Given that V57 already added the UNIQUE constraint with a rationale comment ("raw-SQL import or concurrent bypass of JPA could"), the case for V63 is consistent with the project's constraint discipline. Calling it optional in the issue means it may never get done.GroupControllersecurity tests adds 1–2 hours.Recommendations
group_permissionsmay useINSERT INTO group_permissions (...) VALUES (...) ON CONFLICT DO NOTHINGas an atomic idempotent pattern. Existing JPA@ElementCollectionbehavior (DELETE + INSERT) is unchanged."ALTER TABLE ADD CONSTRAINT PRIMARY KEYon a table with fewer than 100,000 rows acquires a briefACCESS EXCLUSIVElock but completes in milliseconds. No maintenance window required."🎨 Leonie Voss (@leonievoss) — UI/UX Designer
Observations
/admin/groups/<id>is the only user-facing area affected, and the change is purely a correctness fix — the UI already behaves as if duplicates don't exist (JPASet<String>semantics deduplicate transparently)./admin/groups/<id>, edit permissions") should confirm the UI never showed duplicates to begin with, and continues to work after the migration.Set<String>returned by the API, which deduplicates at the JPA layer regardless of the underlying table state. The migration cannot introduce a new visible state.Recommendations
GroupController.getAllGroups()returnsuserService.getAllGroups()which callsgroupRepository.findAll()(JPA), this is already correctly using the Set-deduplicated path. No UI change needed.🗳️ Decision Queue — Open Items for Human Judgment
Three genuine tradeoffs were raised across the review. No blocking decisions, but each affects the implementation scope.
Theme 1: Constrain
permissionto known enum values via CHECKRaised by: Nora (Security)
A
CHECK (permission IN ('READ_ALL', 'WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION', 'ANNOTATE_ALL', 'BLOG_WRITE'))constraint would prevent garbage strings from enteringgroup_permissionsvia raw SQL or a future migration bug.Tradeoff: Every new
Permissionenum value would require a companion Flyway migration to widen the CHECK — coupling the permission enum and the database schema. Currently, adding a new permission (e.g., V59 addsBLOG_WRITE) requires only the seed migration, not a schema alteration.Options:
Theme 2: How to seed duplicate fixtures in the migration test
Raised by: Sara (QA)
To meaningfully test V61 (the dedup migration), the test needs a database state with duplicate
(group_id, permission)rows before V61 runs. There are two approaches:Option A — Flyway
targetversion: Run all migrations up to V60, insert duplicates viaJdbcTemplate, then apply V61+V62 and assert. Requires Flywaytargetconfiguration in the test.Option B — Raw SQL fixture after all migrations: Run all migrations (V61+V62 applied), then test only the V62 post-state (PK exists, no duplicates). Simpler but does not actually test that V61's dedup query executed correctly — it only tests the end state.
Recommendation from Sara: Option A (test V61 in action, not just the end state).
Theme 3: Automate the production pre-flight check in CI
Raised by: Tobias (DevOps)
The pre-flight duplicate check (
SELECT ... GROUP BY ... HAVING COUNT(*) > 1) could be added as a CI step that runs against a production DB snapshot, or kept as a manual deploy checklist item.Tradeoff: Automating requires a production DB snapshot in CI (adds infrastructure and secrets exposure risk). For this project's scale, manual is sufficient and lower-risk.
Recommendation from Tobias: Keep manual for now; document in the PR description as a required deploy checklist step.
Implemented in PR #492 (
fix/issue-469-group-permissions-pk).Commits:
3fcdfa85— V63 (dedup) + V64 (PK + NOT NULL) + V65 (tbmp rename) + 4 migration integration testseb54a98e—createGroupbuilder + null-guard + unit testAll 5 acceptance criteria satisfied. 1551 backend tests green.