Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,6 @@ Backend/VTA.API/Assets/Categories
.DS_Store

# JetBrains IDEs
.idea/
.idea/
# Claude Code
CLAUDE.md
2 changes: 1 addition & 1 deletion Backend/SyncService.Tests/Helpers/HubFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public void OnMessage(string methodName, Action<object?[]> handler)
};
}

public ClaimsPrincipal CreateTestUser(string userId = "test-user-id", string userName = "TestUser")
public ClaimsPrincipal CreateTestUser(string userId = "999", string userName = "TestUser")
{
var claims = new List<Claim>
{
Expand Down
73 changes: 26 additions & 47 deletions Backend/SyncService.Tests/IntegrationTests/BoardHubTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ public class BoardHubTests
private readonly IPresenceService _presence;
private readonly ISessionService _sessions;
private readonly IBoardSyncRelay _syncRelay;

private const string SessionId = "test-session-id";

private const string BoardId = "test-board-id";

private const string ArtifactId = "test-artifact-id";
private const string UserId = "test-user-id";

private const int UserId = 999;

private const string CallerId = "test-caller-id";
private const string CalleeId = "test-callee-id";


public BoardHubTests(DatabaseFixture dbFixture, ITestOutputHelper output)
{
_fixture = new HubFixture();
Expand Down Expand Up @@ -248,15 +248,15 @@ public async Task OnDisconnectedAsync_WithException_Completes()
public async Task ArtifactAdded_WithValidSession_Completes_With_ArtifactRejected()
{
var payload = CreateArtifactAddedPayload();

var jsonString = JsonSerializer.Serialize(payload);
var data = JsonDocument.Parse(jsonString).RootElement;

var hub = CreateHub();

var artifactRejectedReceived = false;
string? errorMessage = null;

_fixture.OnMessage("ArtifactRejected", args =>
{
artifactRejectedReceived = true;
Expand All @@ -266,43 +266,42 @@ public async Task ArtifactAdded_WithValidSession_Completes_With_ArtifactRejected
_output.WriteLine($"[ArtifactRejected]: {message}");
}
});

await hub.ArtifactAdded(data);

_fixture.MockClients.Verify(c => c.Caller, Times.Once);

Assert.True(artifactRejectedReceived, "ArtifactRejected message was not sent to caller");
Assert.NotNull(errorMessage);
Assert.Equal("Artifact not found or does not belong to you", errorMessage);
}

[Fact]
public async Task ArtifactAdded_WithValidSession_Completes_With_ArtifactAdded()
{
await AddTestUserToDb();
await AddTestArtifactToDb();

var payload = CreateArtifactAddedPayload();

var jsonString = JsonSerializer.Serialize(payload);
var data = JsonDocument.Parse(jsonString).RootElement;

var hub = CreateHub();

var artifactAddedReceived = false;

_fixture.OnMessage("ArtifactAdded", args =>
{
artifactAddedReceived = true;
_output.WriteLine($"[ArtifactAdded] Message sent to OthersInGroup with {args.Length} arguments");
});

await hub.ArtifactAdded(data);

_fixture.MockClients.Verify(c => c.OthersInGroup(SessionId), Times.Once);

Assert.True(artifactAddedReceived, "ArtifactAdded message was not sent to OthersInGroup");

await CleanupTestData();
}

Expand All @@ -324,7 +323,7 @@ private ArtifactAddedPayload CreateArtifactAddedPayload()
}
};
}

private async Task AddTestArtifactToDb()
{
var artifact = new Artefact
Expand All @@ -340,19 +339,6 @@ private async Task AddTestArtifactToDb()
await _dbFixture.DbContext.SaveChangesAsync();
}

private async Task AddTestUserToDb()
{
var user = new User
{
Id = UserId,
Name = "Test User",
Password = "hashedpassword",
Username = "TestUser"
};
_dbFixture.DbContext.Users.Add(user);
await _dbFixture.DbContext.SaveChangesAsync();
}

private async Task CleanupTestData()
{
var artifactToDelete = _dbFixture.DbContext.Artefacts.FirstOrDefault(a => a.ArtefactId == ArtifactId);
Expand All @@ -361,13 +347,6 @@ private async Task CleanupTestData()
_dbFixture.DbContext.Artefacts.Remove(artifactToDelete);
await _dbFixture.DbContext.SaveChangesAsync();
}

var userToDelete = _dbFixture.DbContext.Users.FirstOrDefault(u => u.Id == UserId);
if (userToDelete != null)
{
_dbFixture.DbContext.Users.Remove(userToDelete);
await _dbFixture.DbContext.SaveChangesAsync();
}
}
}
}
}
18 changes: 10 additions & 8 deletions Backend/SyncService/Hubs/BoardHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ public async Task AcceptSession(string sessionId, string fromUserId, string toUs
{
context.Sessions.Add(new Session
{
CallerId = fromUserId,
CalleeId = toUserId,
CallerId = int.Parse(fromUserId),
CalleeId = int.Parse(toUserId),
StartTime = DateTime.UtcNow,
CallStatus = CallStatus.Accepted
});
Expand All @@ -175,8 +175,8 @@ public async Task RejectSession(string fromUserId)
{
context.Sessions.Add(new Session
{
CallerId = fromUserId,
CalleeId = currentUserId,
CallerId = int.Parse(fromUserId),
CalleeId = int.Parse(currentUserId),
StartTime = DateTime.UtcNow,
CallStatus = CallStatus.Rejected
});
Expand Down Expand Up @@ -228,9 +228,9 @@ public async Task ArtifactAdded(JsonElement data)
}

var artifact = payload.Artifact;
var userId = Context.User?.FindFirst("id")?.Value;
var userIdStr = Context.User?.FindFirst("sub")?.Value;

if (string.IsNullOrEmpty(userId))
if (!int.TryParse(userIdStr, out var userId))
{
await Clients.Caller.SendAsync("ArtifactRejected", data, "Unauthorized: No user ID in context");
return;
Expand Down Expand Up @@ -363,9 +363,11 @@ private async Task UpdateSessionEndInDb(Models.BoardSession boardSession, CallSt
{
try
{
var user1Id = int.Parse(boardSession.User1Id);
var user2Id = int.Parse(boardSession.User2Id);
var dbSession = await context.Sessions
.Where(s => (s.CallerId == boardSession.User1Id && s.CalleeId == boardSession.User2Id) ||
(s.CallerId == boardSession.User2Id && s.CalleeId == boardSession.User1Id))
.Where(s => (s.CallerId == user1Id && s.CalleeId == user2Id) ||
(s.CallerId == user2Id && s.CalleeId == user1Id))
.Where(s => s.CallStatus == CallStatus.Accepted)
.OrderByDescending(s => s.StartTime)
.FirstOrDefaultAsync();
Expand Down
19 changes: 4 additions & 15 deletions Backend/SyncService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,48 +34,37 @@
var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET")
?? config["Secret:SecretKey"];


if (string.IsNullOrEmpty(jwtSecretKey))
{
throw new ArgumentNullException("JWT_SECRET_KEY environment variable or SecretKey in appsettings.json is required.");
throw new ArgumentNullException("JWT_SECRET environment variable or Secret:SecretKey in appsettings.json is required.");
}
/*Configure Json Web Tokens*/
var jwtIssuer = "api.vta.com";
var jwtAudience = "user.vta.com";

// Configure JWT validation for Core-issued tokens
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})

.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey)),
ClockSkew = TimeSpan.Zero,
RoleClaimType = "role"
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];

// If the request is for our hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/boardHub"))
{
// Read the token out of the query string
context.Token = accessToken;
}
return Task.CompletedTask;
Expand Down
21 changes: 11 additions & 10 deletions Backend/SyncService/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"AllowedHosts": "*",
"Secret": {
"SecretKey":"your-secret-key-change-this-in-production-minimum-32-characters-long",
"ValidIssuer":"api.vta.com",
"ValidAudience":"user.vta.com"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
"Secret": {
"SecretKey": "your-secret-key-change-this-in-production-minimum-32-characters-long"
},
"GirafCore": {
"BaseUrl": "http://localhost:8000"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
88 changes: 88 additions & 0 deletions Backend/VTA.API/Authorization/JwtOrgRoleHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;

namespace VTA.API.Authorization;

/// <summary>
/// Reads the org_roles claim from a Core-issued JWT and checks the user's role
/// for the organization specified in the route ({orgId}).
/// Role hierarchy: owner > admin > member.
/// </summary>
public class JwtOrgRoleHandler : IAuthorizationHandler
{
private static readonly Dictionary<string, int> RoleLevels = new()
{
["member"] = 0,
["admin"] = 1,
["owner"] = 2,
};

private readonly IHttpContextAccessor _httpContextAccessor;

public JwtOrgRoleHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}

public Task HandleAsync(AuthorizationHandlerContext context)
{
foreach (var requirement in context.PendingRequirements.ToList())
{
var minRole = requirement switch
{
OrgOwnerRequirement => "owner",
OrgAdminRequirement => "admin",
OrgMemberRequirement => "member",
_ => null
};

if (minRole is null)
continue;

if (HasMinRole(context.User, minRole))
context.Succeed(requirement);
else
context.Fail();
}

return Task.CompletedTask;
}

private bool HasMinRole(ClaimsPrincipal user, string minRole)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
return false;

var orgIdInUrl = httpContext.Request.RouteValues["orgId"]?.ToString();
if (string.IsNullOrEmpty(orgIdInUrl))
return false;

var orgRoles = GetOrgRoles(user);
if (orgRoles is null || !orgRoles.TryGetValue(orgIdInUrl, out var userRole))
return false;

if (!RoleLevels.TryGetValue(userRole, out var userLevel) ||
!RoleLevels.TryGetValue(minRole, out var requiredLevel))
return false;

return userLevel >= requiredLevel;
}

private static Dictionary<string, string>? GetOrgRoles(ClaimsPrincipal user)
{
var orgRolesClaim = user.FindFirst("org_roles")?.Value;
if (string.IsNullOrEmpty(orgRolesClaim))
return null;

try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(orgRolesClaim);
}
catch
{
return null;
}
}
}
5 changes: 5 additions & 0 deletions Backend/VTA.API/Authorization/OrgAdminRequirement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;

namespace VTA.API.Authorization;

public class OrgAdminRequirement : IAuthorizationRequirement;
5 changes: 5 additions & 0 deletions Backend/VTA.API/Authorization/OrgMemberRequirement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;

namespace VTA.API.Authorization;

public class OrgMemberRequirement : IAuthorizationRequirement;
Loading
Loading