diff --git a/.gitignore b/.gitignore index b3942c3..5eafce8 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ dlldata.c # .NET Core project.lock.json project.fragment.lock.json +packages.lock.json artifacts/ **/Properties/launchSettings.json diff --git a/Visage.Services.UserProfile/Program.cs b/Visage.Services.UserProfile/Program.cs index e644b6c..e4cf6b5 100644 --- a/Visage.Services.UserProfile/Program.cs +++ b/Visage.Services.UserProfile/Program.cs @@ -250,10 +250,31 @@ } }).RequireAuthorization(); -// Get all users endpoint -app.MapGet("/api/users", async Task> (UserDB db) => +// Get all users endpoint - restricted to caller's profile only +app.MapGet("/api/users", async Task, NotFound, UnauthorizedHttpResult>> ( + UserDB db, + HttpContext httpContext, + ILogger logger) => { - return await db.Users.ToListAsync(); + // Extract authenticated user's sub claim + var auth0Sub = httpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(auth0Sub)) + { + logger.LogWarning("GET /api/users: Authentication required"); + return TypedResults.Unauthorized(); + } + + // Return only the caller's profile + var user = await db.Users + .FirstOrDefaultAsync(u => u.Auth0Subject == auth0Sub); + + if (user == null) + { + logger.LogWarning("GET /api/users: User not found for Auth0Subject {Auth0Subject}", auth0Sub); + return TypedResults.NotFound(); + } + + return TypedResults.Ok(user); }).RequireAuthorization(); // Event registration endpoint @@ -322,20 +343,52 @@ }).RequireAuthorization(); // Get user's event registrations -app.MapGet("/api/users/{userId}/registrations", async Task> ( +app.MapGet("/api/users/{userId}/registrations", async Task>, UnauthorizedHttpResult, ForbidHttpResult, BadRequest>> ( string userId, UserDB db, + HttpContext httpContext, ILogger logger) => { if (!StrictId.Id.TryParse(userId, out var parsedUserId)) { logger.LogWarning("Invalid userId format: {UserId}", userId); - return []; + return TypedResults.BadRequest("Invalid userId format"); + } + + // Extract authenticated user's sub claim + var auth0Sub = httpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(auth0Sub)) + { + logger.LogWarning("GET /api/users/{userId}/registrations: Authentication required", userId); + return TypedResults.Unauthorized(); + } + + // Get the authenticated user's ID + var authenticatedUser = await db.Users + .FirstOrDefaultAsync(u => u.Auth0Subject == auth0Sub); + + if (authenticatedUser == null) + { + logger.LogWarning("GET /api/users/{userId}/registrations: Authenticated user not found for Auth0Subject {Auth0Subject}", userId, auth0Sub); + return TypedResults.Unauthorized(); + } + + // Check if the caller is the owner or an admin + bool isOwner = authenticatedUser.Id == parsedUserId; + bool isAdmin = httpContext.User.IsInRole("Admin"); + + if (!isOwner && !isAdmin) + { + logger.LogWarning("Unauthorized access attempt to user {UserId} registrations by {CallerAuth0Subject}", + userId, auth0Sub); + return TypedResults.Forbid(); } - return await db.EventRegistrations + var registrations = await db.EventRegistrations .Where(r => r.UserId == parsedUserId) .ToListAsync(); + + return TypedResults.Ok>(registrations); }).RequireAuthorization(); // Legacy endpoint for backward compatibility @@ -367,26 +420,21 @@ { inputUser.Email = inputUser.Email.Trim(); + // Lookup by Auth0Subject to ensure ownership is the primary key var existing = await db.Users .OrderByDescending(u => u.ProfileCompletedAt) - .FirstOrDefaultAsync(u => u.Email == inputUser.Email); + .FirstOrDefaultAsync(u => u.Auth0Subject == auth0Subject); if (existing is null) { + inputUser.Auth0Subject = auth0Subject; inputUser.CreatedAt = DateTime.UtcNow; await db.Users.AddAsync(inputUser); await db.SaveChangesAsync(); - logger.LogInformation("Created user for {Email}", inputUser.Email); + logger.LogInformation("Created user for Auth0Subject {Auth0Subject}", auth0Subject); return TypedResults.Created($"/register/{inputUser.Id}", inputUser); } - // Enforce that only the authenticated owner can update their profile - if (!string.Equals(existing.Auth0Subject, auth0Subject, StringComparison.Ordinal)) - { - logger.LogWarning("Registration upsert rejected: Auth0 subject mismatch for {Email}", inputUser.Email); - return TypedResults.BadRequest(); - } - // Update allowed fields existing.FirstName = inputUser.FirstName; existing.MiddleName = inputUser.MiddleName; @@ -433,9 +481,30 @@ } }).RequireAuthorization(); -app.MapGet("/register", async Task> (UserDB db) => +app.MapGet("/register", async Task, NotFound, UnauthorizedHttpResult>> ( + UserDB db, + HttpContext httpContext, + ILogger logger) => { - return await db.Users.ToListAsync(); + // Extract authenticated user's sub claim + var auth0Sub = httpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(auth0Sub)) + { + logger.LogWarning("GET /register: Authentication required"); + return TypedResults.Unauthorized(); + } + + // Return only the caller's profile + var user = await db.Users + .FirstOrDefaultAsync(u => u.Auth0Subject == auth0Sub); + + if (user == null) + { + logger.LogWarning("GET /register: User not found for Auth0Subject {Auth0Subject}", auth0Sub); + return TypedResults.NotFound(); + } + + return TypedResults.Ok(user); }).RequireAuthorization(); ProfileApi.MapProfileEndpoints(app); diff --git a/services/Visage.Services.Eventing/EventDB.cs b/services/Visage.Services.Eventing/EventDB.cs index 54f48d9..ebd3640 100644 --- a/services/Visage.Services.Eventing/EventDB.cs +++ b/services/Visage.Services.Eventing/EventDB.cs @@ -68,6 +68,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion() .HasMaxLength(50); + // Ignore cross-DbContext navigation and scalar properties + // These belong to UserProfile DB and should not be mapped in EventDB + entity.Ignore(e => e.User); + entity.Ignore(e => e.UserId); + // Composite index for fast registration lookups (EventId + Auth0Subject) entity.HasIndex(e => new { e.EventId, e.Auth0Subject }) .IsUnique() diff --git a/services/Visage.Services.Eventing/Program.cs b/services/Visage.Services.Eventing/Program.cs index 88d0d2c..ae4c9e3 100644 --- a/services/Visage.Services.Eventing/Program.cs +++ b/services/Visage.Services.Eventing/Program.cs @@ -475,7 +475,7 @@ static async Task, UnauthorizedHttpResult, ForbidHttp "Checked out successfully")); } -static async Task, NotFound, Ok>> LookupByPin( +static async Task>> LookupByPin( string pin, EventDB db, HttpContext http) @@ -484,7 +484,7 @@ static async Task, NotFound, Ok>> var auth0Sub = http.User.FindFirst("sub")?.Value; if (string.IsNullOrEmpty(auth0Sub)) { - return TypedResults.BadRequest("Authentication required"); + return TypedResults.Unauthorized(); } // Fast lookup using CheckInPin index @@ -497,7 +497,17 @@ static async Task, NotFound, Ok>> return TypedResults.NotFound(); } - return TypedResults.Ok(registration); + // Map to DTO to avoid exposing sensitive fields + var dto = new RegistrationDto( + registration.Id, + registration.EventId, + registration.Auth0Subject, + registration.RegisteredAt, + registration.Status, + registration.CheckInPin + ); + + return TypedResults.Ok(dto); } // Helper methods @@ -514,6 +524,17 @@ static string GenerateCheckInPin() // Use shared contracts so frontend and tests can reuse models. // (Defined in `Visage.Shared.Models.CheckInDtos`) +/// +/// Limited DTO for registration lookup to avoid exposing sensitive fields +/// +record RegistrationDto( + StrictId.Id Id, + StrictId.Id EventId, + string Auth0Subject, + DateTime RegisteredAt, + RegistrationStatus Status, + string? CheckInPin +);