chore: migrate user identity from auth0_sub to UUID #219

Merged
egullickson merged 10 commits from issue-206-migrate-user-identity-uuid into main 2026-02-16 20:55:41 +00:00
6 changed files with 137 additions and 140 deletions
Showing only changes of commit 3b1112a9fe - Show all commits

View File

@@ -126,7 +126,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email
FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub
LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause}
ORDER BY al.created_at DESC
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
@@ -170,7 +170,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email
FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub
LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause}
ORDER BY al.created_at DESC
LIMIT ${MAX_EXPORT_RECORDS}

View File

@@ -45,12 +45,12 @@ export class BackupController {
request: FastifyRequest<{ Body: CreateBackupBody }>,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
const result = await this.backupService.createBackup({
name: request.body.name,
backupType: 'manual',
createdBy: adminSub,
createdBy: adminUserId,
includeDocuments: request.body.includeDocuments,
});
@@ -58,7 +58,7 @@ export class BackupController {
// Log backup creation to unified audit log
await auditLogService.info(
'system',
adminSub || null,
adminUserId || null,
`Backup created: ${request.body.name || 'Manual backup'}`,
'backup',
result.backupId,
@@ -74,7 +74,7 @@ export class BackupController {
// Log backup failure
await auditLogService.error(
'system',
adminSub || null,
adminUserId || null,
`Backup failed: ${request.body.name || 'Manual backup'}`,
'backup',
result.backupId,
@@ -139,7 +139,7 @@ export class BackupController {
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
// Handle multipart file upload
const data = await request.file();
@@ -173,7 +173,7 @@ export class BackupController {
const backup = await this.backupService.importUploadedBackup(
tempPath,
filename,
adminSub
adminUserId
);
reply.status(201).send({
@@ -217,7 +217,7 @@ export class BackupController {
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const adminUserId = request.userContext?.userId;
try {
const result = await this.restoreService.executeRestore({
@@ -229,7 +229,7 @@ export class BackupController {
// Log successful restore to unified audit log
await auditLogService.info(
'system',
adminSub || null,
adminUserId || null,
`Backup restored: ${request.params.id}`,
'backup',
request.params.id,
@@ -246,7 +246,7 @@ export class BackupController {
// Log restore failure
await auditLogService.error(
'system',
adminSub || null,
adminUserId || null,
`Backup restore failed: ${request.params.id}`,
'backup',
request.params.id,

View File

@@ -33,7 +33,7 @@ export class OcrController {
request: FastifyRequest<{ Querystring: ExtractQuery }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
const preprocess = request.query.preprocess !== false;
logger.info('OCR extract requested', {
@@ -140,7 +140,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('VIN extract requested', {
operation: 'ocr.controller.extractVin',
@@ -240,7 +240,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('Receipt extract requested', {
operation: 'ocr.controller.extractReceipt',
@@ -352,7 +352,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('Maintenance receipt extract requested', {
operation: 'ocr.controller.extractMaintenanceReceipt',
@@ -460,7 +460,7 @@ export class OcrController {
request: FastifyRequest,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('Manual extract requested', {
operation: 'ocr.controller.extractManual',
@@ -584,7 +584,7 @@ export class OcrController {
request: FastifyRequest<{ Body: JobSubmitBody }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
logger.info('OCR job submit requested', {
operation: 'ocr.controller.submitJob',
@@ -691,7 +691,7 @@ export class OcrController {
request: FastifyRequest<{ Params: JobIdParams }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const userId = request.userContext?.userId as string;
const { jobId } = request.params;
logger.debug('OCR job status requested', {

View File

@@ -18,11 +18,12 @@ import {
export class UserProfileController {
private userProfileService: UserProfileService;
private userProfileRepository: UserProfileRepository;
constructor() {
const repository = new UserProfileRepository(pool);
this.userProfileRepository = new UserProfileRepository(pool);
const adminRepository = new AdminRepository(pool);
this.userProfileService = new UserProfileService(repository);
this.userProfileService = new UserProfileService(this.userProfileRepository);
this.userProfileService.setAdminRepository(adminRepository);
}
@@ -31,27 +32,24 @@ export class UserProfileController {
*/
async getProfile(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Get user data from Auth0 token
const auth0User = {
sub: auth0Sub,
email: (request as any).user?.email || request.userContext?.email || '',
name: (request as any).user?.name,
};
// Get profile by UUID (auth plugin ensures profile exists during authentication)
const profile = await this.userProfileRepository.getById(userId);
// Get or create profile
const profile = await this.userProfileService.getOrCreateProfile(
auth0Sub,
auth0User
);
if (!profile) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
return reply.code(200).send(profile);
} catch (error: any) {
@@ -75,9 +73,9 @@ export class UserProfileController {
reply: FastifyReply
) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
@@ -96,9 +94,9 @@ export class UserProfileController {
const updates = validation.data;
// Update profile
// Update profile by UUID
const profile = await this.userProfileService.updateProfile(
auth0Sub,
userId,
updates
);
@@ -138,9 +136,9 @@ export class UserProfileController {
reply: FastifyReply
) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
@@ -159,9 +157,9 @@ export class UserProfileController {
const { confirmationText } = validation.data;
// Request deletion (user is already authenticated via JWT)
// Request deletion by UUID
const profile = await this.userProfileService.requestDeletion(
auth0Sub,
userId,
confirmationText
);
@@ -210,17 +208,17 @@ export class UserProfileController {
*/
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Cancel deletion
const profile = await this.userProfileService.cancelDeletion(auth0Sub);
// Cancel deletion by UUID
const profile = await this.userProfileService.cancelDeletion(userId);
return reply.code(200).send({
message: 'Account deletion canceled successfully',
@@ -258,27 +256,24 @@ export class UserProfileController {
*/
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const auth0Sub = request.userContext?.userId;
const userId = request.userContext?.userId;
if (!auth0Sub) {
if (!userId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing',
});
}
// Get user data from Auth0 token
const auth0User = {
sub: auth0Sub,
email: (request as any).user?.email || request.userContext?.email || '',
name: (request as any).user?.name,
};
// Get profile by UUID (auth plugin ensures profile exists)
const profile = await this.userProfileRepository.getById(userId);
// Get or create profile
const profile = await this.userProfileService.getOrCreateProfile(
auth0Sub,
auth0User
);
if (!profile) {
return reply.code(404).send({
error: 'Not Found',
message: 'User profile not found',
});
}
const deletionStatus = this.userProfileService.getDeletionStatus(profile);

View File

@@ -194,7 +194,7 @@ export class UserProfileRepository {
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
return {
...this.mapRowToUserProfile(row),
isAdmin: !!row.admin_auth0_sub,
isAdmin: !!row.admin_id,
adminRole: row.admin_role || null,
vehicleCount: parseInt(row.vehicle_count, 10) || 0,
};
@@ -262,7 +262,7 @@ export class UserProfileRepository {
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.id as admin_id,
au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.id
@@ -300,7 +300,7 @@ export class UserProfileRepository {
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.id as admin_id,
au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.id

View File

@@ -60,7 +60,7 @@ export class UserProfileService {
}
/**
* Get user profile by Auth0 sub
* Get user profile by Auth0 sub (used during auth flow)
*/
async getProfile(auth0Sub: string): Promise<UserProfile | null> {
try {
@@ -72,10 +72,10 @@ export class UserProfileService {
}
/**
* Update user profile
* Update user profile by UUID
*/
async updateProfile(
auth0Sub: string,
userId: string,
updates: UpdateProfileRequest
): Promise<UserProfile> {
try {
@@ -85,17 +85,17 @@ export class UserProfileService {
}
// Perform the update
const profile = await this.repository.update(auth0Sub, updates);
const profile = await this.repository.update(userId, updates);
logger.info('User profile updated', {
auth0Sub,
userId,
profileId: profile.id,
updatedFields: Object.keys(updates),
});
return profile;
} catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates });
logger.error('Error updating user profile', { error, userId, updates });
throw error;
}
}
@@ -117,29 +117,29 @@ export class UserProfileService {
}
/**
* Get user details with admin status (admin-only)
* Get user details with admin status by UUID (admin-only)
*/
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> {
async getUserDetails(userId: string): Promise<UserWithAdminStatus | null> {
try {
return await this.repository.getUserWithAdminStatus(auth0Sub);
return await this.repository.getUserWithAdminStatus(userId);
} catch (error) {
logger.error('Error getting user details', { error, auth0Sub });
logger.error('Error getting user details', { error, userId });
throw error;
}
}
/**
* Update user subscription tier (admin-only)
* Update user subscription tier by UUID (admin-only)
* Logs the change to admin audit logs
*/
async updateSubscriptionTier(
auth0Sub: string,
userId: string,
tier: SubscriptionTier,
actorAuth0Sub: string
actorUserId: string
): Promise<UserProfile> {
try {
// Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -147,14 +147,14 @@ export class UserProfileService {
const previousTier = currentUser.subscriptionTier;
// Perform the update
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier);
const updatedProfile = await this.repository.updateSubscriptionTier(userId, tier);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'UPDATE_TIER',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{ previousTier, newTier: tier }
@@ -162,36 +162,36 @@ export class UserProfileService {
}
logger.info('User subscription tier updated', {
auth0Sub,
userId,
previousTier,
newTier: tier,
actorAuth0Sub,
actorUserId,
});
return updatedProfile;
} catch (error) {
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub });
logger.error('Error updating subscription tier', { error, userId, tier, actorUserId });
throw error;
}
}
/**
* Deactivate user account (admin-only soft delete)
* Deactivate user account by UUID (admin-only soft delete)
* Prevents self-deactivation
*/
async deactivateUser(
auth0Sub: string,
actorAuth0Sub: string,
userId: string,
actorUserId: string,
reason?: string
): Promise<UserProfile> {
try {
// Prevent self-deactivation
if (auth0Sub === actorAuth0Sub) {
if (userId === actorUserId) {
throw new Error('Cannot deactivate your own account');
}
// Verify user exists and is not already deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -200,14 +200,14 @@ export class UserProfileService {
}
// Perform the deactivation
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub);
const deactivatedProfile = await this.repository.deactivateUser(userId, actorUserId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'DEACTIVATE_USER',
auth0Sub,
userId,
'user_profile',
deactivatedProfile.id,
{ reason: reason || 'No reason provided' }
@@ -215,28 +215,28 @@ export class UserProfileService {
}
logger.info('User deactivated', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
reason,
});
return deactivatedProfile;
} catch (error) {
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error deactivating user', { error, userId, actorUserId });
throw error;
}
}
/**
* Reactivate a deactivated user account (admin-only)
* Reactivate a deactivated user account by UUID (admin-only)
*/
async reactivateUser(
auth0Sub: string,
actorAuth0Sub: string
userId: string,
actorUserId: string
): Promise<UserProfile> {
try {
// Verify user exists and is deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -245,14 +245,14 @@ export class UserProfileService {
}
// Perform the reactivation
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub);
const reactivatedProfile = await this.repository.reactivateUser(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'REACTIVATE_USER',
auth0Sub,
userId,
'user_profile',
reactivatedProfile.id,
{ previouslyDeactivatedBy: currentUser.deactivatedBy }
@@ -260,29 +260,29 @@ export class UserProfileService {
}
logger.info('User reactivated', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
});
return reactivatedProfile;
} catch (error) {
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error reactivating user', { error, userId, actorUserId });
throw error;
}
}
/**
* Admin update of user profile (email, displayName)
* Admin update of user profile by UUID (email, displayName)
* Logs the change to admin audit logs
*/
async adminUpdateProfile(
auth0Sub: string,
userId: string,
updates: { email?: string; displayName?: string },
actorAuth0Sub: string
actorUserId: string
): Promise<UserProfile> {
try {
// Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
const currentUser = await this.repository.getById(userId);
if (!currentUser) {
throw new Error('User not found');
}
@@ -293,14 +293,14 @@ export class UserProfileService {
};
// Perform the update
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates);
const updatedProfile = await this.repository.adminUpdateProfile(userId, updates);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'UPDATE_PROFILE',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{
@@ -311,14 +311,14 @@ export class UserProfileService {
}
logger.info('User profile updated by admin', {
auth0Sub,
userId,
updatedFields: Object.keys(updates),
actorAuth0Sub,
actorUserId,
});
return updatedProfile;
} catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub });
logger.error('Error admin updating user profile', { error, userId, updates, actorUserId });
throw error;
}
}
@@ -328,12 +328,12 @@ export class UserProfileService {
// ============================================
/**
* Request account deletion
* Request account deletion by UUID
* Sets 30-day grace period before permanent deletion
* Note: User is already authenticated via JWT, confirmation text is sufficient
*/
async requestDeletion(
auth0Sub: string,
userId: string,
confirmationText: string
): Promise<UserProfile> {
try {
@@ -343,7 +343,7 @@ export class UserProfileService {
}
// Get user profile
const profile = await this.repository.getByAuth0Sub(auth0Sub);
const profile = await this.repository.getById(userId);
if (!profile) {
throw new Error('User not found');
}
@@ -354,14 +354,14 @@ export class UserProfileService {
}
// Request deletion
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
const updatedProfile = await this.repository.requestDeletion(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
userId,
'REQUEST_DELETION',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{
@@ -371,42 +371,42 @@ export class UserProfileService {
}
logger.info('Account deletion requested', {
auth0Sub,
userId,
deletionScheduledFor: updatedProfile.deletionScheduledFor,
});
return updatedProfile;
} catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub });
logger.error('Error requesting account deletion', { error, userId });
throw error;
}
}
/**
* Cancel pending deletion request
* Cancel pending deletion request by UUID
*/
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
async cancelDeletion(userId: string): Promise<UserProfile> {
try {
// Cancel deletion
const updatedProfile = await this.repository.cancelDeletion(auth0Sub);
const updatedProfile = await this.repository.cancelDeletion(userId);
// Log to audit trail
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
auth0Sub,
userId,
'CANCEL_DELETION',
auth0Sub,
userId,
'user_profile',
updatedProfile.id,
{}
);
}
logger.info('Account deletion canceled', { auth0Sub });
logger.info('Account deletion canceled', { userId });
return updatedProfile;
} catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub });
logger.error('Error canceling account deletion', { error, userId });
throw error;
}
}
@@ -438,22 +438,22 @@ export class UserProfileService {
}
/**
* Admin hard delete user (permanent deletion)
* Admin hard delete user by UUID (permanent deletion)
* Prevents self-delete
*/
async adminHardDeleteUser(
auth0Sub: string,
actorAuth0Sub: string,
userId: string,
actorUserId: string,
reason?: string
): Promise<void> {
try {
// Prevent self-delete
if (auth0Sub === actorAuth0Sub) {
if (userId === actorUserId) {
throw new Error('Cannot delete your own account');
}
// Get user profile before deletion for audit log
const profile = await this.repository.getByAuth0Sub(auth0Sub);
const profile = await this.repository.getById(userId);
if (!profile) {
throw new Error('User not found');
}
@@ -461,9 +461,9 @@ export class UserProfileService {
// Log to audit trail before deletion
if (this.adminRepository) {
await this.adminRepository.logAuditAction(
actorAuth0Sub,
actorUserId,
'HARD_DELETE_USER',
auth0Sub,
userId,
'user_profile',
profile.id,
{
@@ -475,18 +475,20 @@ export class UserProfileService {
}
// Hard delete from database
await this.repository.hardDeleteUser(auth0Sub);
await this.repository.hardDeleteUser(userId);
// Delete from Auth0
await auth0ManagementClient.deleteUser(auth0Sub);
// Delete from Auth0 (using auth0Sub for Auth0 API)
if (profile.auth0Sub) {
await auth0ManagementClient.deleteUser(profile.auth0Sub);
}
logger.info('User hard deleted by admin', {
auth0Sub,
actorAuth0Sub,
userId,
actorUserId,
reason,
});
} catch (error) {
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub });
logger.error('Error hard deleting user', { error, userId, actorUserId });
throw error;
}
}