bug(user): UserDataInitializer blind-INSERTs Administrators group; fails on retry (HIGH, prod-blocking) #518

Closed
opened 2026-05-11 17:25:37 +02:00 by marcel · 0 comments
Owner

Summary

UserDataInitializer.initAdminUser does a blind groupRepository.save(adminGroup) for Administrators. If a previous boot got partway through the seed (created the group but failed the admin user, OR seeded an admin with a different email that later got cleaned up while leaving the group), the next boot violates the unique constraint user_groups_name_key on name and the application context fails to load.

This is exactly the recovery path operators take to fix a misconfigured admin (delete the bad user, restart, expect re-seed). The recovery path is currently a foot-gun.

Code

// UserDataInitializer.java:50-54
UserGroup adminGroup = UserGroup.builder()
        .name("Administrators")
        .permissions(Set.of("ADMIN", ...))
        .build();
groupRepository.save(adminGroup);   // blind INSERT

The same file already uses findByName(...).orElseGet(...) correctly in initE2EData for the "Leser" group.

Reproduction (just observed on staging)

  1. Boot with the wrong admin email — group + user get created.
  2. Operator fixes APP_ADMIN_USERNAME, deletes the orphan user + group via psql, restarts.
  3. Boot retries: findByEmail(newEmail).isEmpty() == true → proceeds to seed → groupRepository.save(new UserGroup("Administrators"))ERROR: duplicate key value violates unique constraint "user_groups_name_key".

(In our case the orphan group was on the previous boot's seed — the row had to be manually DELETEd from user_groups, group_permissions, and app_users_groups to unblock.)

Impact

  • 🔴 Production blocker. Any failed-then-retried admin seed (network blip, profile-check throw, env-var fix) leaves the backend unable to start until a DB admin manually wipes the group.
  • 🟡 Staging recovery friction. Just blocked us live during the #497 first deploy.

Fix

UserGroup adminGroup = groupRepository.findByName("Administrators").orElseGet(() ->
    groupRepository.save(UserGroup.builder()
            .name("Administrators")
            .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL",
                                "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
            .build()));

Plus a test in AdminSeedFailClosedTest (or a new test file) that pins this idempotency: given a pre-existing Administrators group, a fresh admin seed must succeed and use that group, not throw.

Discovered

While retrying the staging seed after #513 + #514 + #515 + #517 landed — initial boot had seeded the wrong admin, and the post-fix boot retried into the constraint.

## Summary `UserDataInitializer.initAdminUser` does a blind `groupRepository.save(adminGroup)` for `Administrators`. If a previous boot got partway through the seed (created the group but failed the admin user, OR seeded an admin with a different email that later got cleaned up while leaving the group), the next boot violates the unique constraint `user_groups_name_key` on `name` and the application context fails to load. This is exactly the recovery path operators take to fix a misconfigured admin (delete the bad user, restart, expect re-seed). The recovery path is currently a foot-gun. ## Code ```java // UserDataInitializer.java:50-54 UserGroup adminGroup = UserGroup.builder() .name("Administrators") .permissions(Set.of("ADMIN", ...)) .build(); groupRepository.save(adminGroup); // blind INSERT ``` The same file already uses `findByName(...).orElseGet(...)` correctly in `initE2EData` for the "Leser" group. ## Reproduction (just observed on staging) 1. Boot with the wrong admin email — group + user get created. 2. Operator fixes APP_ADMIN_USERNAME, deletes the orphan user + group via psql, restarts. 3. Boot retries: `findByEmail(newEmail).isEmpty() == true` → proceeds to seed → `groupRepository.save(new UserGroup("Administrators"))` → `ERROR: duplicate key value violates unique constraint "user_groups_name_key"`. (In our case the orphan group was on the previous boot's seed — the row had to be manually DELETEd from `user_groups`, `group_permissions`, and `app_users_groups` to unblock.) ## Impact - **🔴 Production blocker.** Any failed-then-retried admin seed (network blip, profile-check throw, env-var fix) leaves the backend unable to start until a DB admin manually wipes the group. - **🟡 Staging recovery friction.** Just blocked us live during the #497 first deploy. ## Fix ```java UserGroup adminGroup = groupRepository.findByName("Administrators").orElseGet(() -> groupRepository.save(UserGroup.builder() .name("Administrators") .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION")) .build())); ``` Plus a test in `AdminSeedFailClosedTest` (or a new test file) that pins this idempotency: given a pre-existing Administrators group, a fresh admin seed must succeed and *use* that group, not throw. ## Discovered While retrying the staging seed after #513 + #514 + #515 + #517 landed — initial boot had seeded the wrong admin, and the post-fix boot retried into the constraint.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#518