Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions backend/MIGRATION_GUID.md
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
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Mvc;
using PhotoBank.AccessControl;
using PhotoBank.ViewModel.Dto;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -70,7 +71,7 @@ public async Task<ActionResult> Delete(int id, CancellationToken ct)
[HttpPost("{id:int}/assign-user/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AssignUser(int id, string userId, CancellationToken ct)
public async Task<ActionResult> AssignUser(int id, Guid userId, CancellationToken ct)
{
var result = await _profiles.AssignUserAsync(id, userId, ct);
return result ? NoContent() : NotFound();
Expand All @@ -79,7 +80,7 @@ public async Task<ActionResult> AssignUser(int id, string userId, CancellationTo
[HttpDelete("{id:int}/assign-user/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UnassignUser(int id, string userId, CancellationToken ct)
public async Task<ActionResult> UnassignUser(int id, Guid userId, CancellationToken ct)
{
var result = await _profiles.UnassignUserAsync(id, userId, ct);
return result ? NoContent() : NotFound();
Expand All @@ -88,7 +89,7 @@ public async Task<ActionResult> UnassignUser(int id, string userId, Cancellation
[HttpPost("{id:int}/assign-role/{roleId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AssignRole(int id, string roleId, CancellationToken ct)
public async Task<ActionResult> AssignRole(int id, Guid roleId, CancellationToken ct)
{
var result = await _profiles.AssignRoleAsync(id, roleId, ct);
return result ? NoContent() : NotFound();
Expand All @@ -97,7 +98,7 @@ public async Task<ActionResult> AssignRole(int id, string roleId, CancellationTo
[HttpDelete("{id:int}/assign-role/{roleId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UnassignRole(int id, string roleId, CancellationToken ct)
public async Task<ActionResult> UnassignRole(int id, Guid roleId, CancellationToken ct)
{
var result = await _profiles.UnassignRoleAsync(id, roleId, ct);
return result ? NoContent() : NotFound();
Expand Down
9 changes: 5 additions & 4 deletions backend/PhotoBank.Api/Controllers/Admin/UsersController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PhotoBank.Services.Identity;
Expand Down Expand Up @@ -43,7 +44,7 @@ public async Task<IActionResult> CreateAsync([FromBody] CreateUserDto dto)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UpdateAsync(string id, [FromBody] UpdateUserDto dto)
public async Task<IActionResult> UpdateAsync(Guid id, [FromBody] UpdateUserDto dto)
{
var result = await adminUserService.UpdateAsync(id, dto);
if (result.NotFound)
Expand All @@ -64,7 +65,7 @@ public async Task<IActionResult> UpdateAsync(string id, [FromBody] UpdateUserDto
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteAsync(string id)
public async Task<IActionResult> DeleteAsync(Guid id)
{
var result = await adminUserService.DeleteAsync(id);
if (result.NotFound)
Expand All @@ -79,7 +80,7 @@ public async Task<IActionResult> DeleteAsync(string id)
[HttpPost("{id}/reset-password")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetPasswordAsync(string id, [FromBody] ResetPasswordDto dto)
public async Task<IActionResult> ResetPasswordAsync(Guid id, [FromBody] ResetPasswordDto dto)
{
var result = await adminUserService.ResetPasswordAsync(id, dto);
if (result.NotFound)
Expand All @@ -94,7 +95,7 @@ public async Task<IActionResult> ResetPasswordAsync(string id, [FromBody] ResetP
[HttpPut("{id}/roles")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetRolesAsync(string id, [FromBody] SetRolesDto dto)
public async Task<IActionResult> SetRolesAsync(Guid id, [FromBody] SetRolesDto dto)
{
var result = await adminUserService.SetRolesAsync(id, dto);
if (result.NotFound)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Update access-profile APIs to use GUID identifiers

The link entities now expose RoleAccessProfile.RoleId and UserAccessProfile.UserId as Guid, but the rest of the access-profile API still treats these values as strings. AccessProfileService.AssignUserAsync/UnassignUserAsync/AssignRoleAsync and the corresponding controller actions continue to accept string userId/string roleId and compare them with x.UserId == userId or x.RoleId == roleId, which no longer compiles once the properties are Guid. The public routes also still accept {userId} as an arbitrary string. All of these call sites need to switch to Guid (or explicitly convert to string) to keep the project building and to ensure the API matches the new identifier type.

Useful? React with 👍 / 👎.

Comment on lines 47 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge EffectiveAccess provider still queries by string IDs

Changing UserAccessProfile.UserId and RoleAccessProfile.RoleId to Guid requires the access provider to query using GUIDs as well. EffectiveAccessProvider.GetAsync still accepts a string userId and builds queries like roleIds.Contains(rp.RoleId) and _db.UserAccessProfiles.Where(up => up.UserId == userId), where roleIds is a List<string>. After this change the code no longer compiles, because the LINQ expressions compare string to Guid. IEffectiveAccessProvider and its implementation need to accept/parse GUIDs and adjust the queries accordingly.

Useful? React with 👍 / 👎.

public int ProfileId { get; set; }
public AccessProfile Profile { get; set; } = default!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add migration for GUID Identity change

Switching PhotoBankDbContext to IdentityDbContext<ApplicationUser, ApplicationRole, Guid> means all ASP.NET Identity tables now expect GUID primary keys, but this commit does not update the EF migrations or snapshot (they still describe IdentityRole and related tables with string IDs). Applying this code to an existing database will leave the schema out of sync and cause runtime failures when RoleManager/UserManager try to persist Guid values to text columns. A migration converting the Identity tables and join tables to uuid columns is required.

Useful? React with 👍 / 👎.

{
public static readonly ILoggerFactory PhotoBankLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); });

Expand Down
Loading