diff --git a/server-dotnet/src/RoomServer/Controllers/ArtifactEndpoints.cs b/server-dotnet/src/RoomServer/Controllers/ArtifactEndpoints.cs index 1a58156..a981407 100644 --- a/server-dotnet/src/RoomServer/Controllers/ArtifactEndpoints.cs +++ b/server-dotnet/src/RoomServer/Controllers/ArtifactEndpoints.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using RoomServer.Models; using RoomServer.Services; using RoomServer.Services.ArtifactStore; @@ -33,20 +34,30 @@ public static IEndpointRouteBuilder MapArtifactEndpoints(this IEndpointRouteBuil return app; } - private static async Task HandleWriteRoomArtifact(HttpContext context, string roomId, IArtifactStore store, RoomEventPublisher publisher, CancellationToken ct) + private static async Task HandleWriteRoomArtifact(HttpContext context, string roomId, IArtifactStore store, RoomEventPublisher publisher, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleWriteArtifact(context, roomId, null, "room", store, publisher, ct); + return await HandleWriteArtifact(context, roomId, null, "room", store, publisher, sessions, permissions, ct); } - private static async Task HandleWriteEntityArtifact(HttpContext context, string roomId, string entityId, IArtifactStore store, RoomEventPublisher publisher, CancellationToken ct) + private static async Task HandleWriteEntityArtifact(HttpContext context, string roomId, string entityId, IArtifactStore store, RoomEventPublisher publisher, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleWriteArtifact(context, roomId, entityId, "entity", store, publisher, ct); + return await HandleWriteArtifact(context, roomId, entityId, "entity", store, publisher, sessions, permissions, ct); } - private static async Task HandleWriteArtifact(HttpContext context, string roomId, string? entityId, string workspace, IArtifactStore store, RoomEventPublisher publisher, CancellationToken ct) + private static async Task HandleWriteArtifact(HttpContext context, string roomId, string? entityId, string workspace, IArtifactStore store, RoomEventPublisher publisher, SessionStore sessions, PermissionService permissions, CancellationToken ct) { try { + if (!TryResolveSession(context, roomId, sessions, out var session, out var errorResult)) + { + return errorResult!; + } + + if (!permissions.CanAccessWorkspace(session, workspace, entityId)) + { + return ErrorFactory.HttpForbidden("PERM_DENIED", "not allowed to access workspace"); + } + var form = await context.Request.ReadFormAsync(ct).ConfigureAwait(false); var specJson = form["spec"].FirstOrDefault(); var file = form.Files["data"]; @@ -85,7 +96,7 @@ private static async Task HandleWriteArtifact(HttpContext context, stri } var originEntity = workspace == "entity" ? entityId : form["entityId"].FirstOrDefault(); - originEntity ??= context.User?.Identity?.Name; + originEntity ??= session.Entity.Id; await using var dataStream = file.OpenReadStream(); var request = new ArtifactWriteRequest( @@ -114,20 +125,30 @@ private static async Task HandleWriteArtifact(HttpContext context, stri } } - private static async Task HandleReadRoomArtifact(HttpContext context, string roomId, string name, IArtifactStore store, CancellationToken ct) + private static async Task HandleReadRoomArtifact(HttpContext context, string roomId, string name, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleReadArtifact(context, roomId, null, name, "room", store, ct); + return await HandleReadArtifact(context, roomId, null, name, "room", store, sessions, permissions, ct); } - private static async Task HandleReadEntityArtifact(HttpContext context, string roomId, string entityId, string name, IArtifactStore store, CancellationToken ct) + private static async Task HandleReadEntityArtifact(HttpContext context, string roomId, string entityId, string name, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleReadArtifact(context, roomId, entityId, name, "entity", store, ct); + return await HandleReadArtifact(context, roomId, entityId, name, "entity", store, sessions, permissions, ct); } - private static async Task HandleReadArtifact(HttpContext context, string roomId, string? entityId, string name, string workspace, IArtifactStore store, CancellationToken ct) + private static async Task HandleReadArtifact(HttpContext context, string roomId, string? entityId, string name, string workspace, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { try { + if (!TryResolveSession(context, roomId, sessions, out var session, out var errorResult)) + { + return errorResult!; + } + + if (!permissions.CanAccessWorkspace(session, workspace, entityId)) + { + return ErrorFactory.HttpForbidden("PERM_DENIED", "not allowed to access workspace"); + } + var stream = await store.ReadAsync(new ArtifactReadRequest(roomId, workspace, entityId, name), ct).ConfigureAwait(false); var download = string.Equals(context.Request.Query["download"], "true", StringComparison.OrdinalIgnoreCase); var fileName = download ? name : null; @@ -139,20 +160,30 @@ private static async Task HandleReadArtifact(HttpContext context, strin } } - private static async Task HandleListRoomArtifacts(HttpContext context, string roomId, IArtifactStore store, CancellationToken ct) + private static async Task HandleListRoomArtifacts(HttpContext context, string roomId, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleListArtifacts(context, roomId, null, "room", store, ct); + return await HandleListArtifacts(context, roomId, null, "room", store, sessions, permissions, ct); } - private static async Task HandleListEntityArtifacts(HttpContext context, string roomId, string entityId, IArtifactStore store, CancellationToken ct) + private static async Task HandleListEntityArtifacts(HttpContext context, string roomId, string entityId, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { - return await HandleListArtifacts(context, roomId, entityId, "entity", store, ct); + return await HandleListArtifacts(context, roomId, entityId, "entity", store, sessions, permissions, ct); } - private static async Task HandleListArtifacts(HttpContext context, string roomId, string? entityId, string workspace, IArtifactStore store, CancellationToken ct) + private static async Task HandleListArtifacts(HttpContext context, string roomId, string? entityId, string workspace, IArtifactStore store, SessionStore sessions, PermissionService permissions, CancellationToken ct) { try { + if (!TryResolveSession(context, roomId, sessions, out var session, out var errorResult)) + { + return errorResult!; + } + + if (!permissions.CanAccessWorkspace(session, workspace, entityId)) + { + return ErrorFactory.HttpForbidden("PERM_DENIED", "not allowed to access workspace"); + } + if (!TryParseListParameters(context.Request, out var listParams, out var errorResult)) { return errorResult!; @@ -178,16 +209,26 @@ private static async Task HandleListArtifacts(HttpContext context, stri } } - private static async Task HandlePromoteArtifact(HttpContext context, string roomId, IArtifactStore store, RoomEventPublisher publisher, CancellationToken ct) + private static async Task HandlePromoteArtifact(HttpContext context, string roomId, IArtifactStore store, RoomEventPublisher publisher, SessionStore sessions, PermissionService permissions, CancellationToken ct) { try { + if (!TryResolveSession(context, roomId, sessions, out var session, out var errorResult)) + { + return errorResult!; + } + var payload = await context.Request.ReadFromJsonAsync(SpecSerializerOptions, ct).ConfigureAwait(false); if (payload is null || string.IsNullOrWhiteSpace(payload.FromEntity) || string.IsNullOrWhiteSpace(payload.Name)) { return Results.Json(new { error = "InvalidPromoteRequest", message = "fromEntity and name are required" }, statusCode: StatusCodes.Status400BadRequest); } + if (!permissions.CanPromote(session, payload.FromEntity!)) + { + return ErrorFactory.HttpForbidden("PERM_DENIED", "not allowed to promote artifact"); + } + var metadata = payload.Metadata is null ? null : new Dictionary(payload.Metadata); var manifest = await store.PromoteAsync(new ArtifactPromoteRequest(roomId, payload.FromEntity!, payload.Name!, payload.As, metadata), ct).ConfigureAwait(false); await PublishArtifactEvents(publisher, roomId, manifest).ConfigureAwait(false); @@ -204,6 +245,28 @@ private static async Task HandlePromoteArtifact(HttpContext context, st } } + private static bool TryResolveSession(HttpContext context, string roomId, SessionStore sessions, out EntitySession? session, out IResult? error) + { + session = null; + error = null; + + var entityId = context.Request.Headers["X-Entity-Id"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(entityId)) + { + error = ErrorFactory.HttpUnauthorized("AUTH_REQUIRED", "X-Entity-Id header is required"); + return false; + } + + session = sessions.GetByRoomAndEntity(roomId, entityId); + if (session is null) + { + error = ErrorFactory.HttpForbidden("PERM_DENIED", "active session not found for entity"); + return false; + } + + return true; + } + private static async Task PublishArtifactEvents(RoomEventPublisher publisher, string roomId, ArtifactManifest manifest) { var eventType = manifest.Version == 1 ? "ARTIFACT.ADDED" : "ARTIFACT.UPDATED"; diff --git a/server-dotnet/src/RoomServer/Hubs/RoomHub.cs b/server-dotnet/src/RoomServer/Hubs/RoomHub.cs index b6b5535..71b7ddd 100644 --- a/server-dotnet/src/RoomServer/Hubs/RoomHub.cs +++ b/server-dotnet/src/RoomServer/Hubs/RoomHub.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using System.Threading.Tasks; +using NUlid; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -using NUlid; using RoomServer.Models; using RoomServer.Services; @@ -11,77 +13,284 @@ namespace RoomServer.Hubs; public class RoomHub : Hub { - private readonly RoomManager _manager; + private readonly SessionStore _sessions; + private readonly PermissionService _permissions; private readonly RoomEventPublisher _events; private readonly ILogger _logger; - public RoomHub(RoomManager manager, RoomEventPublisher events, ILogger logger) + public RoomHub(SessionStore sessions, PermissionService permissions, RoomEventPublisher events, ILogger logger) { - _manager = manager; + _sessions = sessions; + _permissions = permissions; _events = events; _logger = logger; } public override async Task OnDisconnectedAsync(Exception? exception) { - var removed = _manager.RemoveConnection(Context.ConnectionId); - foreach (var (roomId, entity) in removed) + var removed = _sessions.RemoveByConnection(Context.ConnectionId); + if (removed is not null) { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); - await _events.PublishAsync(roomId, "ENTITY.LEAVE", new { entityId = entity.Id }); - await PublishRoomState(roomId); - _logger.LogInformation("[{RoomId}] {EntityId} disconnected ({Kind})", roomId, entity.Id, entity.Kind); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, removed.RoomId); + await _events.PublishAsync(removed.RoomId, "ENTITY.LEAVE", new { entityId = removed.Entity.Id }); + await PublishRoomState(removed.RoomId); + _logger.LogInformation("[{RoomId}] {EntityId} disconnected ({Kind})", removed.RoomId, removed.Entity.Id, removed.Entity.Kind); } await base.OnDisconnectedAsync(exception); } - public async Task Join(string roomId, EntityInfo entity) + public async Task> Join(string roomId, EntitySpec entity) { - var stored = _manager.AddEntity(roomId, entity, Context.ConnectionId); + ArgumentException.ThrowIfNullOrWhiteSpace(roomId); + ArgumentNullException.ThrowIfNull(entity); + ValidateEntity(entity); + + var userId = ResolveUserId(); + if (string.Equals(entity.Visibility, "owner", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(entity.OwnerUserId)) + { + throw ErrorFactory.HubBadRequest("INVALID_ENTITY_SPEC", "owner visibility requires owner_user_id"); + } + + if (string.IsNullOrWhiteSpace(userId)) + { + throw ErrorFactory.HubForbidden("AUTH_REQUIRED", "owner visibility requires authentication"); + } + + if (!string.Equals(entity.OwnerUserId, userId, StringComparison.Ordinal)) + { + throw ErrorFactory.HubForbidden("PERM_DENIED", "owner mismatch"); + } + } + + userId ??= entity.OwnerUserId; + + _sessions.RemoveByConnection(Context.ConnectionId); + + var normalized = NormalizeEntity(entity); + var session = new EntitySession + { + ConnectionId = Context.ConnectionId, + RoomId = roomId, + Entity = normalized, + JoinedAt = DateTime.UtcNow, + UserId = userId + }; + + _sessions.Add(session); await Groups.AddToGroupAsync(Context.ConnectionId, roomId); - await _events.PublishAsync(roomId, "ENTITY.JOIN", new { entity = stored }); + await _events.PublishAsync(roomId, "ENTITY.JOIN", new { entity = normalized }); await PublishRoomState(roomId); - _logger.LogInformation("[{RoomId}] {EntityId} joined ({Kind})", roomId, stored.Id, stored.Kind); + _logger.LogInformation("[{RoomId}] {EntityId} joined ({Kind})", roomId, normalized.Id, normalized.Kind); + + return _sessions.ListByRoom(roomId).Select(s => s.Entity).ToList(); } public async Task Leave(string roomId, string entityId) { - var removed = _manager.RemoveEntity(roomId, entityId, Context.ConnectionId); - await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); + var session = _sessions.GetByConnection(Context.ConnectionId) ?? throw ErrorFactory.HubUnauthorized("AUTH_REQUIRED", "session not found"); - if (removed is not null) + if (!string.Equals(session.RoomId, roomId, StringComparison.Ordinal) || + !string.Equals(session.Entity.Id, entityId, StringComparison.Ordinal)) { - await _events.PublishAsync(roomId, "ENTITY.LEAVE", new { entityId = removed.Id }); - await PublishRoomState(roomId); - _logger.LogInformation("[{RoomId}] {EntityId} left ({Kind})", roomId, removed.Id, removed.Kind); + throw ErrorFactory.HubForbidden("PERM_DENIED", "cannot leave on behalf of another entity"); } + + _sessions.RemoveByConnection(Context.ConnectionId); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); + + await _events.PublishAsync(roomId, "ENTITY.LEAVE", new { entityId = session.Entity.Id }); + await PublishRoomState(roomId); + + _logger.LogInformation("[{RoomId}] {EntityId} left ({Kind})", roomId, session.Entity.Id, session.Entity.Kind); } public async Task SendToRoom(string roomId, MessageModel message) { + ArgumentException.ThrowIfNullOrWhiteSpace(roomId); ArgumentNullException.ThrowIfNull(message); + var fromSession = _sessions.GetByConnection(Context.ConnectionId) ?? throw ErrorFactory.HubUnauthorized("AUTH_REQUIRED", "session not found"); + if (!string.Equals(fromSession.RoomId, roomId, StringComparison.Ordinal)) + { + throw ErrorFactory.HubForbidden("PERM_DENIED", "cannot publish to different room"); + } + + if (!string.Equals(message.From, fromSession.Entity.Id, StringComparison.Ordinal)) + { + throw ErrorFactory.HubForbidden("PERM_DENIED", "message sender mismatch"); + } + message.RoomId = roomId; message.Ts = DateTime.UtcNow; + message.Id = string.IsNullOrWhiteSpace(message.Id) ? Ulid.NewUlid().ToString() : message.Id; - if (string.IsNullOrWhiteSpace(message.Id)) + if (IsDirectMessage(message.Channel)) { - message.Id = Ulid.NewUlid().ToString(); + await HandleDirectMessage(roomId, message, fromSession); + } + else + { + await HandleRoomMessage(roomId, message, fromSession); } - await Clients.Group(roomId).SendAsync("message", message); _logger.LogInformation("[{RoomId}] {From} → {Channel} :: {Type}", roomId, message.From, message.Channel, message.Type); } - public Task> ListEntities(string roomId) - => Task.FromResult(_manager.GetEntities(roomId)); + public Task> ListEntities(string roomId) + { + var entities = _sessions.ListByRoom(roomId).Select(s => s.Entity).ToList(); + return Task.FromResult>(entities); + } + + private async Task HandleDirectMessage(string roomId, MessageModel message, EntitySession fromSession) + { + var targetId = message.Channel[1..]; + if (string.IsNullOrWhiteSpace(targetId)) + { + throw ErrorFactory.HubBadRequest("INVALID_TARGET", "direct messages require a target"); + } + + var targetSession = _sessions.GetByRoomAndEntity(roomId, targetId); + if (targetSession is null) + { + throw ErrorFactory.HubNotFound("TARGET_NOT_FOUND", "target not connected"); + } + + if (!_permissions.CanDirectMessage(fromSession, targetSession.Entity)) + { + throw ErrorFactory.HubForbidden("PERM_DENIED", "not allowed to direct message target"); + } + + await Clients.Clients(new[] { fromSession.ConnectionId, targetSession.ConnectionId }).SendAsync("message", message); + } + + private async Task HandleRoomMessage(string roomId, MessageModel message, EntitySession fromSession) + { + if (string.Equals(message.Type, "command", StringComparison.OrdinalIgnoreCase)) + { + var targetId = ResolveCommandTarget(message.Payload); + if (string.IsNullOrWhiteSpace(targetId)) + { + throw ErrorFactory.HubBadRequest("INVALID_COMMAND", "command payload must include target"); + } + + var target = _sessions.GetByRoomAndEntity(roomId, targetId)?.Entity; + if (target is null) + { + throw ErrorFactory.HubNotFound("TARGET_NOT_FOUND", "command target not connected"); + } + + if (!_permissions.CanSendCommand(fromSession, target)) + { + throw ErrorFactory.HubForbidden("PERM_DENIED", "not allowed to command this target"); + } + } + + await Clients.Group(roomId).SendAsync("message", message); + } + + /// + /// Extracts the command target field from payloads that may arrive in a handful of + /// serialization shapes (System.Text.Json primitives or dictionaries produced by JSON + /// converters). This keeps the type matching localized so the rest of the hub code can stay + /// agnostic about how the client serialized the command. + /// Supported payload representations: + /// + /// with + /// with object root + /// values (object or string) + /// + /// Any other payload shapes are treated as missing the target field and return null. + /// + private static string? ResolveCommandTarget(object payload) + { + switch (payload) + { + case JsonElement element when element.ValueKind == JsonValueKind.Object: + return element.TryGetProperty("target", out var value) ? value.GetString() : null; + case JsonDocument document when document.RootElement.ValueKind == JsonValueKind.Object: + return document.RootElement.TryGetProperty("target", out var value) ? value.GetString() : null; + case IDictionary dict: + return dict.TryGetValue("target", out var value) ? value?.ToString() : null; + case IDictionary dictString: + return dictString.TryGetValue("target", out var value) ? value : null; + default: + return null; + } + } + + private static bool IsDirectMessage(string? channel) + => !string.IsNullOrWhiteSpace(channel) && channel.StartsWith("@", StringComparison.Ordinal); + + private static EntitySpec NormalizeEntity(EntitySpec entity) + { + var normalized = new EntitySpec + { + Id = entity.Id, + Kind = entity.Kind, + DisplayName = entity.DisplayName, + Visibility = string.IsNullOrWhiteSpace(entity.Visibility) ? "team" : entity.Visibility, + OwnerUserId = entity.OwnerUserId, + Capabilities = entity.Capabilities ?? Array.Empty(), + Policy = entity.Policy ?? new PolicySpec() + }; + + normalized.Policy.AllowCommandsFrom = string.IsNullOrWhiteSpace(normalized.Policy.AllowCommandsFrom) + ? "any" + : normalized.Policy.AllowCommandsFrom; + normalized.Policy.EnvWhitelist ??= Array.Empty(); + + return normalized; + } + + private static void ValidateEntity(EntitySpec entity) + { + if (string.IsNullOrWhiteSpace(entity.Id)) + { + throw ErrorFactory.HubBadRequest("INVALID_ENTITY_SPEC", "entity.id is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Kind)) + { + throw ErrorFactory.HubBadRequest("INVALID_ENTITY_SPEC", "entity.kind is required"); + } + + if (entity.Policy is null) + { + entity.Policy = new PolicySpec(); + } + + if (entity.Capabilities is null) + { + entity.Capabilities = Array.Empty(); + } + } + + private async Task PublishRoomState(string roomId) + { + var entities = _sessions.ListByRoom(roomId).Select(s => s.Entity).ToList(); + await _events.PublishAsync(roomId, "ROOM.STATE", new { entities }); + } - private Task PublishRoomState(string roomId) + private string? ResolveUserId() { - var entities = _manager.GetEntities(roomId); - return _events.PublishAsync(roomId, "ROOM.STATE", new { entities }); + var fromClaims = Context.User?.FindFirst("sub")?.Value; + if (!string.IsNullOrWhiteSpace(fromClaims)) + { + return fromClaims; + } + + var http = Context.GetHttpContext(); + if (http?.Request.Headers.TryGetValue("X-User-Id", out var headerValue) == true && !string.IsNullOrWhiteSpace(headerValue)) + { + return headerValue.ToString(); + } + + return null; } } diff --git a/server-dotnet/src/RoomServer/Models/EntityInfo.cs b/server-dotnet/src/RoomServer/Models/EntityInfo.cs deleted file mode 100644 index e889c3f..0000000 --- a/server-dotnet/src/RoomServer/Models/EntityInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace RoomServer.Models; - -public class EntityInfo -{ - public string Id { get; set; } = default!; - public string Kind { get; set; } = default!; - public string DisplayName { get; set; } = default!; - public string RoomId { get; set; } = default!; - public DateTime JoinedAt { get; set; } = DateTime.UtcNow; -} diff --git a/server-dotnet/src/RoomServer/Models/EntitySession.cs b/server-dotnet/src/RoomServer/Models/EntitySession.cs new file mode 100644 index 0000000..3f89beb --- /dev/null +++ b/server-dotnet/src/RoomServer/Models/EntitySession.cs @@ -0,0 +1,12 @@ +using System; + +namespace RoomServer.Models; + +public sealed class EntitySession +{ + public string ConnectionId { get; set; } = default!; + public string RoomId { get; set; } = default!; + public EntitySpec Entity { get; set; } = default!; + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + public string? UserId { get; set; } +} diff --git a/server-dotnet/src/RoomServer/Models/EntitySpec.cs b/server-dotnet/src/RoomServer/Models/EntitySpec.cs new file mode 100644 index 0000000..92b60fc --- /dev/null +++ b/server-dotnet/src/RoomServer/Models/EntitySpec.cs @@ -0,0 +1,14 @@ +using System; + +namespace RoomServer.Models; + +public sealed class EntitySpec +{ + public string Id { get; set; } = default!; // E-* + public string Kind { get; set; } = default!; // human|agent|npc|orchestrator + public string? DisplayName { get; set; } + public string Visibility { get; set; } = "team"; // public|team|owner + public string? OwnerUserId { get; set; } // obrigatório se visibility==owner + public string[] Capabilities { get; set; } = Array.Empty(); + public PolicySpec Policy { get; set; } = new(); +} diff --git a/server-dotnet/src/RoomServer/Models/ErrorResponse.cs b/server-dotnet/src/RoomServer/Models/ErrorResponse.cs new file mode 100644 index 0000000..0411bd0 --- /dev/null +++ b/server-dotnet/src/RoomServer/Models/ErrorResponse.cs @@ -0,0 +1,3 @@ +namespace RoomServer.Models; + +public sealed record ErrorResponse(string Error, string Code, string Message); diff --git a/server-dotnet/src/RoomServer/Models/PolicySpec.cs b/server-dotnet/src/RoomServer/Models/PolicySpec.cs new file mode 100644 index 0000000..cf60a0f --- /dev/null +++ b/server-dotnet/src/RoomServer/Models/PolicySpec.cs @@ -0,0 +1,10 @@ +using System; + +namespace RoomServer.Models; + +public sealed class PolicySpec +{ + public string AllowCommandsFrom { get; set; } = "any"; // owner|orchestrator|any + public bool SandboxMode { get; set; } + public string[] EnvWhitelist { get; set; } = Array.Empty(); +} diff --git a/server-dotnet/src/RoomServer/Program.cs b/server-dotnet/src/RoomServer/Program.cs index 64526b7..bbb1e27 100644 --- a/server-dotnet/src/RoomServer/Program.cs +++ b/server-dotnet/src/RoomServer/Program.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.SignalR; using RoomServer.Controllers; using RoomServer.Hubs; using RoomServer.Services; @@ -8,9 +7,10 @@ builder.Services.AddSignalR(); builder.Services.AddHealthChecks(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Logging.AddConsole(); var app = builder.Build(); diff --git a/server-dotnet/src/RoomServer/Services/ErrorFactory.cs b/server-dotnet/src/RoomServer/Services/ErrorFactory.cs new file mode 100644 index 0000000..ea7c8ca --- /dev/null +++ b/server-dotnet/src/RoomServer/Services/ErrorFactory.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using RoomServer.Models; + +namespace RoomServer.Services; + +public static class ErrorFactory +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static HubException HubUnauthorized(string code, string message) + => CreateHubException(401, code, message); + + public static HubException HubForbidden(string code, string message) + => CreateHubException(403, code, message); + + public static HubException HubBadRequest(string code, string message) + => CreateHubException(400, code, message); + + public static HubException HubNotFound(string code, string message) + => CreateHubException(404, code, message); + + public static IResult HttpUnauthorized(string code, string message) + => CreateHttpResult(401, code, message); + + public static IResult HttpForbidden(string code, string message) + => CreateHttpResult(403, code, message); + + public static IResult HttpBadRequest(string code, string message) + => CreateHttpResult(400, code, message); + + public static IResult HttpNotFound(string code, string message) + => CreateHttpResult(404, code, message); + + private static HubException CreateHubException(int statusCode, string code, string message) + { + var error = new ErrorResponse(MapError(statusCode), code, message); + return new HubException(JsonSerializer.Serialize(error, SerializerOptions)); + } + + private static IResult CreateHttpResult(int statusCode, string code, string message) + { + var error = new ErrorResponse(MapError(statusCode), code, message); + return Results.Json(error, statusCode: statusCode); + } + + private static string MapError(int statusCode) + => statusCode switch + { + 400 => "BadRequest", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "NotFound", + _ => "Error" + }; +} diff --git a/server-dotnet/src/RoomServer/Services/PermissionService.cs b/server-dotnet/src/RoomServer/Services/PermissionService.cs new file mode 100644 index 0000000..e956315 --- /dev/null +++ b/server-dotnet/src/RoomServer/Services/PermissionService.cs @@ -0,0 +1,60 @@ +using RoomServer.Models; + +namespace RoomServer.Services; + +public sealed class PermissionService +{ + public bool CanSendCommand(EntitySession from, EntitySpec target) + { + ArgumentNullException.ThrowIfNull(from); + ArgumentNullException.ThrowIfNull(target); + + return target.Policy.AllowCommandsFrom switch + { + "any" => true, + "orchestrator" => string.Equals(from.Entity.Kind, "orchestrator", StringComparison.OrdinalIgnoreCase), + "owner" => target.OwnerUserId is not null && string.Equals(target.OwnerUserId, from.UserId, StringComparison.Ordinal), + _ => false + }; + } + + public bool CanAccessWorkspace(EntitySession session, string workspace, string? entityId) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(workspace); + + return workspace switch + { + "room" => true, + "entity" => string.Equals(entityId, session.Entity.Id, StringComparison.Ordinal), + _ => false + }; + } + + public bool CanDirectMessage(EntitySession from, EntitySpec to) + { + ArgumentNullException.ThrowIfNull(from); + ArgumentNullException.ThrowIfNull(to); + + return to.Visibility switch + { + "public" => true, + "team" => true, + "owner" => to.OwnerUserId is not null && string.Equals(to.OwnerUserId, from.UserId, StringComparison.Ordinal), + _ => false + }; + } + + public bool CanPromote(EntitySession session, string fromEntityId) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(fromEntityId); + + if (string.Equals(session.Entity.Kind, "orchestrator", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return string.Equals(session.Entity.Id, fromEntityId, StringComparison.Ordinal); + } +} diff --git a/server-dotnet/src/RoomServer/Services/RoomManager.cs b/server-dotnet/src/RoomServer/Services/RoomManager.cs deleted file mode 100644 index 34f13e6..0000000 --- a/server-dotnet/src/RoomServer/Services/RoomManager.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using RoomServer.Models; - -namespace RoomServer.Services; - -public class RoomManager -{ - private readonly ConcurrentDictionary> _rooms = new(); - private readonly ConcurrentDictionary> _connectionIndex = new(); - - public EntityInfo AddEntity(string roomId, EntityInfo entity, string connectionId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(roomId); - ArgumentNullException.ThrowIfNull(entity); - ArgumentException.ThrowIfNullOrWhiteSpace(entity.Id); - - entity.RoomId = roomId; - entity.JoinedAt = DateTime.UtcNow; - - var room = _rooms.GetOrAdd(roomId, _ => new()); - room[entity.Id] = entity; - - var connections = _connectionIndex.GetOrAdd(connectionId, _ => new()); - connections[(roomId, entity.Id)] = 0; - - return entity; - } - - public EntityInfo? RemoveEntity(string roomId, string entityId, string? connectionId = null) - { - var entity = RemoveEntityFromRoom(roomId, entityId); - - if (connectionId is not null) - { - RemoveConnectionEntry(connectionId, roomId, entityId); - } - else - { - RemoveConnectionEntryFromAll(roomId, entityId); - } - - return entity; - } - - public IReadOnlyCollection GetEntities(string roomId) - { - if (_rooms.TryGetValue(roomId, out var room)) - { - return room.Values.ToList(); - } - - return Array.Empty(); - } - - public IReadOnlyDictionary> GetAll() - => _rooms.ToDictionary(kvp => kvp.Key, kvp => (IReadOnlyCollection)kvp.Value.Values.ToList()); - - public IReadOnlyList<(string RoomId, EntityInfo Entity)> RemoveConnection(string connectionId) - { - if (!_connectionIndex.TryRemove(connectionId, out var entries)) - { - return Array.Empty<(string, EntityInfo)>(); - } - - List<(string RoomId, EntityInfo Entity)> removed = new(); - foreach (var ((roomId, entityId), _) in entries) - { - var entity = RemoveEntityFromRoom(roomId, entityId); - if (entity is not null) - { - removed.Add((roomId, entity)); - } - } - - return removed; - } - - public bool RoomExists(string roomId) => _rooms.ContainsKey(roomId); - - private EntityInfo? RemoveEntityFromRoom(string roomId, string entityId) - { - if (_rooms.TryGetValue(roomId, out var room) && room.TryRemove(entityId, out var entity)) - { - if (room.IsEmpty) - { - _rooms.TryRemove(roomId, out _); - } - - return entity; - } - - return null; - } - - private void RemoveConnectionEntry(string connectionId, string roomId, string entityId) - { - if (_connectionIndex.TryGetValue(connectionId, out var entries)) - { - entries.TryRemove((roomId, entityId), out _); - if (entries.IsEmpty) - { - _connectionIndex.TryRemove(connectionId, out _); - } - } - } - - private void RemoveConnectionEntryFromAll(string roomId, string entityId) - { - foreach (var connectionId in _connectionIndex.Keys) - { - RemoveConnectionEntry(connectionId, roomId, entityId); - } - } -} diff --git a/server-dotnet/src/RoomServer/Services/SessionStore.cs b/server-dotnet/src/RoomServer/Services/SessionStore.cs new file mode 100644 index 0000000..ed0f526 --- /dev/null +++ b/server-dotnet/src/RoomServer/Services/SessionStore.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using RoomServer.Models; + +namespace RoomServer.Services; + +public sealed class SessionStore +{ + private readonly ConcurrentDictionary _byConnection = new(); + private readonly ConcurrentDictionary<(string RoomId, string EntityId), string> _index = new(); + + public void Add(EntitySession session) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(session.ConnectionId); + ArgumentException.ThrowIfNullOrWhiteSpace(session.RoomId); + ArgumentNullException.ThrowIfNull(session.Entity); + ArgumentException.ThrowIfNullOrWhiteSpace(session.Entity.Id); + + _byConnection[session.ConnectionId] = session; + _index[(session.RoomId, session.Entity.Id)] = session.ConnectionId; + } + + public EntitySession? GetByConnection(string connectionId) + => _byConnection.TryGetValue(connectionId, out var session) ? session : null; + + public EntitySession? GetByRoomAndEntity(string roomId, string entityId) + { + if (_index.TryGetValue((roomId, entityId), out var connectionId)) + { + return GetByConnection(connectionId); + } + + return null; + } + + public IReadOnlyList ListByRoom(string roomId) + => _byConnection.Values.Where(s => s.RoomId == roomId).ToList(); + + public EntitySession? RemoveByConnection(string connectionId) + { + if (_byConnection.TryRemove(connectionId, out var session)) + { + _index.TryRemove((session.RoomId, session.Entity.Id), out _); + return session; + } + + return null; + } + + public bool RemoveByRoomAndEntity(string roomId, string entityId) + { + if (_index.TryRemove((roomId, entityId), out var connectionId)) + { + return _byConnection.TryRemove(connectionId, out _); + } + + return false; + } +} diff --git a/server-dotnet/tests/RoomServer.Tests/RoomHub_SmokeTests.cs b/server-dotnet/tests/RoomServer.Tests/RoomHub_SmokeTests.cs index a4238e2..c831385 100644 --- a/server-dotnet/tests/RoomServer.Tests/RoomHub_SmokeTests.cs +++ b/server-dotnet/tests/RoomServer.Tests/RoomHub_SmokeTests.cs @@ -34,7 +34,7 @@ public async Task JoinBroadcastsPresence() }); await connectionA.StartAsync(); - await connectionA.InvokeAsync("Join", RoomId, new EntityInfo + await connectionA.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-A", Kind = "human", @@ -42,7 +42,7 @@ public async Task JoinBroadcastsPresence() }); await connectionB.StartAsync(); - await connectionB.InvokeAsync("Join", RoomId, new EntityInfo + await connectionB.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-B", Kind = "agent", @@ -73,14 +73,14 @@ public async Task SendMessageIsBroadcastToRoom() await connectionA.StartAsync(); await connectionB.StartAsync(); - await connectionA.InvokeAsync("Join", RoomId, new EntityInfo + await connectionA.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-A", Kind = "human", DisplayName = "Alice" }); - await connectionB.InvokeAsync("Join", RoomId, new EntityInfo + await connectionB.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-B", Kind = "agent", @@ -122,14 +122,14 @@ public async Task LeaveBroadcastsEvent() await connectionA.StartAsync(); await connectionB.StartAsync(); - await connectionA.InvokeAsync("Join", RoomId, new EntityInfo + await connectionA.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-A", Kind = "human", DisplayName = "Alice" }); - await connectionB.InvokeAsync("Join", RoomId, new EntityInfo + await connectionB.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-B", Kind = "agent", @@ -164,14 +164,14 @@ public async Task DisconnectPublishesLeaveEvent() await connectionA.StartAsync(); await connectionB.StartAsync(); - await connectionA.InvokeAsync("Join", RoomId, new EntityInfo + await connectionA.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-A", Kind = "human", DisplayName = "Alice" }); - await connectionB.InvokeAsync("Join", RoomId, new EntityInfo + await connectionB.InvokeAsync("Join", RoomId, new EntitySpec { Id = "E-B", Kind = "agent", diff --git a/server-dotnet/tests/RoomServer.Tests/SecurityTests.cs b/server-dotnet/tests/RoomServer.Tests/SecurityTests.cs new file mode 100644 index 0000000..f74bc21 --- /dev/null +++ b/server-dotnet/tests/RoomServer.Tests/SecurityTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.Mvc.Testing; +using RoomServer.Models; +using Xunit; + +namespace RoomServer.Tests; + +public class SecurityTests : IAsyncLifetime +{ + private readonly WebApplicationFactory _factory = new(); + private const string RoomId = "room-security"; + + [Fact] + public async Task JoinOwnerWithoutAuthShouldFail() + { + await using var connection = BuildConnection(); + await connection.StartAsync(); + + var exception = await Assert.ThrowsAsync(() => connection.InvokeAsync>("Join", RoomId, new EntitySpec + { + Id = "E-OWNER", + Kind = "human", + Visibility = "owner", + OwnerUserId = "U-1" + })); + + var error = JsonDocument.Parse(exception.Message); + error.RootElement.GetProperty("code").GetString().Should().Be("AUTH_REQUIRED"); + } + + [Fact] + public async Task JoinOwnerWithAuthSucceeds() + { + await using var connection = BuildConnection(options => options.Headers.Add("X-User-Id", "U-1")); + await connection.StartAsync(); + + var entities = await connection.InvokeAsync>("Join", RoomId, new EntitySpec + { + Id = "E-OWNER", + Kind = "human", + Visibility = "owner", + OwnerUserId = "U-1" + }); + + entities.Should().Contain(e => e.Id == "E-OWNER"); + } + + [Fact] + public async Task CommandDeniedWhenPolicyRequiresOrchestrator() + { + await using var humanConnection = BuildConnection(); + await using var targetConnection = BuildConnection(); + + await humanConnection.StartAsync(); + await targetConnection.StartAsync(); + + await humanConnection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-H", + Kind = "human" + }); + + await targetConnection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-T", + Kind = "agent", + Policy = new PolicySpec { AllowCommandsFrom = "orchestrator" } + }); + + var payload = JsonDocument.Parse("{\"target\":\"E-T\"}").RootElement.Clone(); + + var exception = await Assert.ThrowsAsync(() => humanConnection.InvokeAsync("SendToRoom", RoomId, new MessageModel + { + From = "E-H", + Channel = "room", + Type = "command", + Payload = payload + })); + + var error = JsonDocument.Parse(exception.Message); + error.RootElement.GetProperty("code").GetString().Should().Be("PERM_DENIED"); + } + + [Fact] + public async Task DirectMessageToOwnerFromDifferentUserIsDenied() + { + await using var ownerConnection = BuildConnection(options => options.Headers.Add("X-User-Id", "U-2")); + await using var userConnection = BuildConnection(options => options.Headers.Add("X-User-Id", "U-1")); + + await ownerConnection.StartAsync(); + await userConnection.StartAsync(); + + await ownerConnection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-TARGET", + Kind = "agent", + Visibility = "owner", + OwnerUserId = "U-2" + }); + + await userConnection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-USER", + Kind = "human", + OwnerUserId = "U-1" + }); + + var exception = await Assert.ThrowsAsync(() => userConnection.InvokeAsync("SendToRoom", RoomId, new MessageModel + { + From = "E-USER", + Channel = "@E-TARGET", + Type = "chat", + Payload = new { text = "ping" } + })); + + var error = JsonDocument.Parse(exception.Message); + error.RootElement.GetProperty("code").GetString().Should().Be("PERM_DENIED"); + } + + [Fact] + public async Task PrivateWorkspaceWriteByDifferentEntityIsDenied() + { + await using var connection = BuildConnection(); + await connection.StartAsync(); + + await connection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-A", + Kind = "human" + }); + + using var content = new MultipartFormDataContent(); + content.Add(new StringContent("{\"name\":\"test.txt\",\"type\":\"text/plain\"}", Encoding.UTF8, "application/json"), "spec"); + content.Add(new ByteArrayContent(Encoding.UTF8.GetBytes("hello")) { Headers = { ContentType = new MediaTypeHeaderValue("text/plain") } }, "data", "test.txt"); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Entity-Id", "E-A"); + var response = await client.PostAsync($"/rooms/{RoomId}/entities/E-B/artifacts", content); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.Forbidden); + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + body.RootElement.GetProperty("code").GetString().Should().Be("PERM_DENIED"); + } + + [Fact] + public async Task PromoteDeniedForNonOwner() + { + await using var ownerConnection = BuildConnection(); + await ownerConnection.StartAsync(); + + await ownerConnection.InvokeAsync("Join", RoomId, new EntitySpec + { + Id = "E-A", + Kind = "human" + }); + + using var content = new MultipartFormDataContent(); + content.Add(new StringContent("{\"name\":\"test.txt\",\"type\":\"text/plain\"}", Encoding.UTF8, "application/json"), "spec"); + content.Add(new ByteArrayContent(Encoding.UTF8.GetBytes("hello")) { Headers = { ContentType = new MediaTypeHeaderValue("text/plain") } }, "data", "test.txt"); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Entity-Id", "E-A"); + var uploadResponse = await client.PostAsync($"/rooms/{RoomId}/entities/E-A/artifacts", content); + uploadResponse.EnsureSuccessStatusCode(); + + client.DefaultRequestHeaders.Remove("X-Entity-Id"); + client.DefaultRequestHeaders.Add("X-Entity-Id", "E-B"); + var promotePayload = JsonContent.Create(new { fromEntity = "E-A", name = "test.txt" }); + var promoteResponse = await client.PostAsync($"/rooms/{RoomId}/artifacts/promote", promotePayload); + + promoteResponse.StatusCode.Should().Be(System.Net.HttpStatusCode.Forbidden); + var body = JsonDocument.Parse(await promoteResponse.Content.ReadAsStringAsync()); + body.RootElement.GetProperty("code").GetString().Should().Be("PERM_DENIED"); + } + + private HubConnection BuildConnection(Action? configure = null) + { + return new HubConnectionBuilder() + .WithUrl("http://localhost/room", options => + { + options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); + options.Transports = HttpTransportType.LongPolling; + configure?.Invoke(options); + }) + .WithAutomaticReconnect() + .Build(); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public Task DisposeAsync() => Task.CompletedTask; +}