-
Notifications
You must be signed in to change notification settings - Fork 0
Migrate Identity from string IDs to GUIDs #930
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
552658e
5eb0063
b711044
de53d2f
7fa871d
0baa211
fd01fb8
6cbafca
a45ece5
26bbe66
1b7cf70
f813540
113e8e7
c678e31
26e9c31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| # Migrating ASP.NET Identity from String to Guid IDs | ||
|
|
||
| This document explains how to apply the migration that converts ASP.NET Identity from string-based IDs to Guid-based IDs. | ||
|
|
||
| ## Overview | ||
|
|
||
| The codebase has been updated to use `IdentityDbContext<ApplicationUser, ApplicationRole, Guid>` which changes all Identity table primary keys from `text` to `uuid` in PostgreSQL. | ||
|
|
||
| ## Migrations Created | ||
|
|
||
| 1. **20251114204752_MigrateIdentityToGuid.cs** - Converts Identity tables (AspNetUsers, AspNetRoles, etc.) | ||
| 2. **20251114204752_MigrateAccessProfileToGuid.cs** - Converts AccessControl tables (RoleAccessProfiles, UserAccessProfiles) | ||
|
|
||
| ## Important: Role Name to Role ID Conversion | ||
|
|
||
| ⚠️ **The old code stored role NAMES (e.g., "Admin") in RoleAccessProfiles.RoleId, not role IDs.** | ||
|
|
||
| The AccessControl migration now includes logic to automatically convert: | ||
| - **RoleAccessProfiles.RoleId**: Looks up role names in AspNetRoles to find the corresponding Guid | ||
| - **UserAccessProfiles.UserId**: Attempts to parse as UUID, or looks up by username/email | ||
|
|
||
| Any rows that cannot be mapped (orphaned data) will be deleted during migration. | ||
|
|
||
| ## Important Warnings | ||
|
|
||
| ⚠️ **Identity migrations will fail if you have existing data with non-UUID string IDs.** | ||
|
|
||
| PostgreSQL cannot automatically cast arbitrary strings to UUIDs. You have three options: | ||
|
|
||
| ### Option 1: Fresh Database (Recommended for Development) | ||
|
|
||
| If you don't need to preserve existing data: | ||
|
|
||
| ```bash | ||
| # Drop and recreate the database | ||
| psql -U postgres -c "DROP DATABASE photobank;" | ||
| psql -U postgres -c "CREATE DATABASE photobank;" | ||
|
|
||
| # Apply all migrations from scratch | ||
| cd backend/PhotoBank.Api | ||
| dotnet ef database update --context PhotoBankDbContext | ||
| dotnet ef database update --context AccessControlDbContext | ||
| ``` | ||
|
|
||
| ### Option 2: Existing Data with Valid UUID IDs or Role Names | ||
|
|
||
| If your existing user/role IDs are already valid UUIDs (e.g., `"123e4567-e89b-12d3-a456-426614174000"`), or if you have role names in RoleAccessProfiles: | ||
|
|
||
| ```bash | ||
| cd backend/PhotoBank.Api | ||
|
|
||
| # IMPORTANT: Apply Identity migration FIRST | ||
| # This must complete before AccessControl migration, because the AccessControl | ||
| # migration joins with AspNetRoles to look up role IDs from role names | ||
| dotnet ef database update --context PhotoBankDbContext | ||
|
|
||
| # Then apply AccessControl migration | ||
| # This will automatically convert role names to role IDs | ||
| dotnet ef database update --context AccessControlDbContext | ||
| ``` | ||
|
|
||
| **What happens:** | ||
| 1. Identity migration converts AspNetUsers.Id and AspNetRoles.Id from text to uuid | ||
| 2. AccessControl migration: | ||
| - Looks up role names in AspNetRoles (e.g., "Admin" → corresponding Guid) | ||
| - Converts valid UUID strings directly | ||
| - Looks up usernames/emails in AspNetUsers to find user IDs | ||
| - Deletes any orphaned rows that cannot be mapped | ||
|
|
||
| ### Option 3: Existing Data with Non-UUID String IDs | ||
|
|
||
| If you have existing data with non-UUID IDs (e.g., arbitrary strings), you need a data migration: | ||
|
|
||
| #### Step 1: Create a mapping of old IDs to new UUIDs | ||
|
|
||
| ```sql | ||
| -- Create temporary mapping tables | ||
| CREATE TABLE _user_id_mapping ( | ||
| old_id text PRIMARY KEY, | ||
| new_id uuid NOT NULL DEFAULT gen_random_uuid() | ||
| ); | ||
|
|
||
| CREATE TABLE _role_id_mapping ( | ||
| old_id text PRIMARY KEY, | ||
| new_id uuid NOT NULL DEFAULT gen_random_uuid() | ||
| ); | ||
|
|
||
| -- Populate mappings | ||
| INSERT INTO _user_id_mapping (old_id) | ||
| SELECT "Id" FROM "AspNetUsers"; | ||
|
|
||
| INSERT INTO _role_id_mapping (old_id) | ||
| SELECT "Id" FROM "AspNetRoles"; | ||
| ``` | ||
|
|
||
| #### Step 2: Create custom migration SQL | ||
|
|
||
| Instead of using the generated migrations, create custom SQL: | ||
|
|
||
| ```sql | ||
| -- Start transaction | ||
| BEGIN; | ||
|
|
||
| -- Update AspNetUsers | ||
| ALTER TABLE "AspNetUsers" ADD COLUMN "NewId" uuid; | ||
| UPDATE "AspNetUsers" u SET "NewId" = m.new_id | ||
| FROM _user_id_mapping m WHERE u."Id" = m.old_id; | ||
|
|
||
| ALTER TABLE "AspNetUsers" DROP CONSTRAINT "PK_AspNetUsers"; | ||
| ALTER TABLE "AspNetUsers" DROP COLUMN "Id"; | ||
| ALTER TABLE "AspNetUsers" RENAME COLUMN "NewId" TO "Id"; | ||
| ALTER TABLE "AspNetUsers" ADD PRIMARY KEY ("Id"); | ||
|
|
||
| -- Update AspNetRoles (similar pattern) | ||
| ALTER TABLE "AspNetRoles" ADD COLUMN "NewId" uuid; | ||
| UPDATE "AspNetRoles" r SET "NewId" = m.new_id | ||
| FROM _role_id_mapping m WHERE r."Id" = m.old_id; | ||
|
|
||
| ALTER TABLE "AspNetRoles" DROP CONSTRAINT "PK_AspNetRoles"; | ||
| ALTER TABLE "AspNetRoles" DROP COLUMN "Id"; | ||
| ALTER TABLE "AspNetRoles" RENAME COLUMN "NewId" TO "Id"; | ||
| ALTER TABLE "AspNetRoles" ADD PRIMARY KEY ("Id"); | ||
|
|
||
| -- Update all foreign key references (AspNetUserRoles, AspNetUserClaims, etc.) | ||
| -- ... (repeat for each FK table) | ||
|
|
||
| -- Update AccessControl tables | ||
| ALTER TABLE "UserAccessProfiles" ADD COLUMN "NewUserId" uuid; | ||
| UPDATE "UserAccessProfiles" u SET "NewUserId" = m.new_id | ||
| FROM _user_id_mapping m WHERE u."UserId" = m.old_id; | ||
|
|
||
| ALTER TABLE "UserAccessProfiles" DROP CONSTRAINT "PK_UserAccessProfiles"; | ||
| ALTER TABLE "UserAccessProfiles" DROP COLUMN "UserId"; | ||
| ALTER TABLE "UserAccessProfiles" RENAME COLUMN "NewUserId" TO "UserId"; | ||
| ALTER TABLE "UserAccessProfiles" ADD PRIMARY KEY ("UserId", "ProfileId"); | ||
|
|
||
| -- Similar for RoleAccessProfiles | ||
| -- ... | ||
|
|
||
| -- Clean up mapping tables | ||
| DROP TABLE _user_id_mapping; | ||
| DROP TABLE _role_id_mapping; | ||
|
|
||
| -- Mark migrations as applied | ||
| INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") | ||
| VALUES ('20251114204752_MigrateIdentityToGuid', '9.0.9'); | ||
|
|
||
| COMMIT; | ||
| ``` | ||
|
|
||
| #### Step 3: Update JWT tokens | ||
|
|
||
| After migration, all existing JWT tokens will be invalid because they contain the old string IDs. Users will need to log in again. | ||
|
|
||
| ## Verifying the Migration | ||
|
|
||
| After applying migrations, verify the schema: | ||
|
|
||
| ```sql | ||
| -- Check AspNetUsers.Id is uuid | ||
| SELECT column_name, data_type | ||
| FROM information_schema.columns | ||
| WHERE table_name = 'AspNetUsers' AND column_name = 'Id'; | ||
| -- Expected: uuid | ||
|
|
||
| -- Check AspNetRoles.Id is uuid | ||
| SELECT column_name, data_type | ||
| FROM information_schema.columns | ||
| WHERE table_name = 'AspNetRoles' AND column_name = 'Id'; | ||
| -- Expected: uuid | ||
|
|
||
| -- Check UserAccessProfiles.UserId is uuid | ||
| SELECT column_name, data_type | ||
| FROM information_schema.columns | ||
| WHERE table_name = 'UserAccessProfiles' AND column_name = 'UserId'; | ||
| -- Expected: uuid | ||
|
|
||
| -- Check RoleAccessProfiles.RoleId is uuid | ||
| SELECT column_name, data_type | ||
| FROM information_schema.columns | ||
| WHERE table_name = 'RoleAccessProfiles' AND column_name = 'RoleId'; | ||
| -- Expected: uuid | ||
| ``` | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| ### Error: "cannot cast type text to uuid" | ||
|
|
||
| This means you have existing data with non-UUID string IDs. Follow Option 3 above. | ||
|
|
||
| ### Error: "duplicate key value violates unique constraint" | ||
|
|
||
| This can happen if UUID generation creates duplicates (extremely unlikely) or if your mapping logic is incorrect. | ||
|
|
||
| ### Foreign Key Constraint Violations | ||
|
|
||
| Ensure you update all foreign keys in the correct order: | ||
| 1. AspNetUsers.Id and AspNetRoles.Id (primary keys) | ||
| 2. All tables referencing these keys (foreign keys) | ||
| 3. AccessControl tables last | ||
|
|
||
| ## Rolling Back | ||
|
|
||
| If something goes wrong, you can roll back: | ||
|
|
||
| ```bash | ||
| cd backend/PhotoBank.Api | ||
|
|
||
| # Roll back both contexts | ||
| dotnet ef database update 20251103124346_Empty --context PhotoBankDbContext | ||
| dotnet ef database update 20251103124432_Empty --context AccessControlDbContext | ||
| ``` | ||
|
|
||
| Or restore from your database backup (you did create a backup first, right? 😅) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,13 +46,13 @@ public class AccessProfileDateRangeAllow | |
|
|
||
| public class RoleAccessProfile | ||
| { | ||
| public string RoleId { get; set; } = default!; | ||
| public Guid RoleId { get; set; } | ||
| public int ProfileId { get; set; } | ||
| } | ||
|
|
||
| public class UserAccessProfile | ||
| { | ||
| public string UserId { get; set; } = default!; | ||
| public Guid UserId { get; set; } | ||
|
Comment on lines
47
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Changing Useful? React with 👍 / 👎. |
||
| public int ProfileId { get; set; } | ||
| public AccessProfile Profile { get; set; } = default!; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ namespace PhotoBank.DbContext.DbContext | |
| using Microsoft.EntityFrameworkCore.Internal; | ||
| using Models; | ||
|
|
||
| public class PhotoBankDbContext : IdentityDbContext<ApplicationUser>, IDbContextPoolable | ||
| public class PhotoBankDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDbContextPoolable | ||
|
Comment on lines
12
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Switching Useful? React with 👍 / 👎. |
||
| { | ||
| public static readonly ILoggerFactory PhotoBankLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The link entities now expose
RoleAccessProfile.RoleIdandUserAccessProfile.UserIdasGuid, but the rest of the access-profile API still treats these values as strings.AccessProfileService.AssignUserAsync/UnassignUserAsync/AssignRoleAsyncand the corresponding controller actions continue to acceptstring userId/string roleIdand compare them withx.UserId == userIdorx.RoleId == roleId, which no longer compiles once the properties areGuid. The public routes also still accept{userId}as an arbitrary string. All of these call sites need to switch toGuid(or explicitly convert to string) to keep the project building and to ensure the API matches the new identifier type.Useful? React with 👍 / 👎.