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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dlldata.c
# .NET Core
project.lock.json
project.fragment.lock.json
packages.lock.json
artifacts/
**/Properties/launchSettings.json

Expand Down
103 changes: 86 additions & 17 deletions Visage.Services.UserProfile/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,31 @@
}
}).RequireAuthorization();

// Get all users endpoint
app.MapGet("/api/users", async Task<IEnumerable<User>> (UserDB db) =>
// Get all users endpoint - restricted to caller's profile only
app.MapGet("/api/users", async Task<Results<Ok<User>, NotFound, UnauthorizedHttpResult>> (
UserDB db,
HttpContext httpContext,
ILogger<Program> 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();
Comment on lines +254 to 278
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The security fixes in this PR (authorization checks on /api/users, /api/users/{userId}/registrations, /register endpoints, and LookupByPin) lack test coverage. Given the critical security nature of these changes, consider adding integration tests to verify: 1) users can only access their own profile data, 2) unauthorized access to other users' registrations returns 403, 3) admin role grants appropriate access, and 4) LookupByPin properly restricts access. Reference existing test patterns in tests/Visage.Test.Aspire/ for examples.

Copilot uses AI. Check for mistakes.

// Event registration endpoint
Expand Down Expand Up @@ -322,20 +343,52 @@
}).RequireAuthorization();

// Get user's event registrations
app.MapGet("/api/users/{userId}/registrations", async Task<IEnumerable<EventRegistration>> (
app.MapGet("/api/users/{userId}/registrations", async Task<Results<Ok<IEnumerable<EventRegistration>>, UnauthorizedHttpResult, ForbidHttpResult, BadRequest<string>>> (
string userId,
UserDB db,
HttpContext httpContext,
ILogger<Program> logger) =>
{
if (!StrictId.Id<User>.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");
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The role check uses "Admin" but other parts of the codebase (e.g., CheckOutFromSession in Eventing API) use "VisageAdmin". This inconsistency in role naming could lead to authorization bypass if the actual role name in Auth0 is "VisageAdmin" rather than "Admin". Verify the correct role name and ensure consistency across all authorization checks in the codebase.

Suggested change
bool isAdmin = httpContext.User.IsInRole("Admin");
bool isAdmin = httpContext.User.IsInRole("VisageAdmin");

Copilot uses AI. Check for mistakes.

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<IEnumerable<EventRegistration>>(registrations);
}).RequireAuthorization();

// Legacy endpoint for backward compatibility
Expand Down Expand Up @@ -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;
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The removed Auth0Subject mismatch check was necessary to prevent ownership bypass. Since the lookup now uses Auth0Subject directly, an existing user record will always belong to the authenticated user, making the removed check redundant. However, the redundant assignment on line 430 (inputUser.Auth0Subject = auth0Subject) is unnecessary since inputUser.Auth0Subject was already set on line 411 and won't be used when existing is not null. Consider removing line 430 for code clarity.

Suggested change
inputUser.Auth0Subject = auth0Subject;

Copilot uses AI. Check for mistakes.
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;
Expand Down Expand Up @@ -433,9 +481,30 @@
}
}).RequireAuthorization();

app.MapGet("/register", async Task<IEnumerable<User>> (UserDB db) =>
app.MapGet("/register", async Task<Results<Ok<User>, NotFound, UnauthorizedHttpResult>> (
UserDB db,
HttpContext httpContext,
ILogger<Program> 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);
Expand Down
5 changes: 5 additions & 0 deletions services/Visage.Services.Eventing/EventDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasConversion<string>()
.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()
Expand Down
27 changes: 24 additions & 3 deletions services/Visage.Services.Eventing/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ static async Task<Results<BadRequest<string>, UnauthorizedHttpResult, ForbidHttp
"Checked out successfully"));
}

static async Task<Results<BadRequest<string>, NotFound, Ok<EventRegistration>>> LookupByPin(
static async Task<Results<UnauthorizedHttpResult, NotFound, Ok<RegistrationDto>>> LookupByPin(
string pin,
EventDB db,
HttpContext http)
Expand All @@ -484,7 +484,7 @@ static async Task<Results<BadRequest<string>, NotFound, Ok<EventRegistration>>>
var auth0Sub = http.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(auth0Sub))
{
return TypedResults.BadRequest("Authentication required");
return TypedResults.Unauthorized();
}

// Fast lookup using CheckInPin index
Expand All @@ -497,7 +497,17 @@ static async Task<Results<BadRequest<string>, NotFound, Ok<EventRegistration>>>
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
Expand All @@ -514,6 +524,17 @@ static string GenerateCheckInPin()
// Use shared contracts so frontend and tests can reuse models.
// (Defined in `Visage.Shared.Models.CheckInDtos`)

/// <summary>
/// Limited DTO for registration lookup to avoid exposing sensitive fields
/// </summary>
record RegistrationDto(
StrictId.Id<EventRegistration> Id,
StrictId.Id<Event> EventId,
string Auth0Subject,
DateTime RegisteredAt,
RegistrationStatus Status,
string? CheckInPin
);
Comment on lines +530 to +537
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The RegistrationDto exposes the Auth0Subject field, which is a user identifier that could be considered sensitive. While this may be intentional for staff/admin lookup scenarios, consider whether exposing the Auth0Subject to any authenticated caller is appropriate for your security model. If this endpoint is meant for staff-only use, add role-based authorization. Otherwise, consider removing Auth0Subject from the DTO or redacting it for non-admin users.

Copilot uses AI. Check for mistakes.



Expand Down