diff --git a/Common/Phantom.Common.Data.Web/Users/AuthenticatedUserInfo.cs b/Common/Phantom.Common.Data.Web/Users/AuthenticatedUserInfo.cs index 7db483b..9becdf6 100644 --- a/Common/Phantom.Common.Data.Web/Users/AuthenticatedUserInfo.cs +++ b/Common/Phantom.Common.Data.Web/Users/AuthenticatedUserInfo.cs @@ -1,4 +1,5 @@ -using MemoryPack; +using System.Collections.Immutable; +using MemoryPack; namespace Phantom.Common.Data.Web.Users; @@ -6,5 +7,18 @@ namespace Phantom.Common.Data.Web.Users; public sealed partial record AuthenticatedUserInfo( [property: MemoryPackOrder(0)] Guid Guid, [property: MemoryPackOrder(1)] string Name, - [property: MemoryPackOrder(2)] PermissionSet Permissions -); + [property: MemoryPackOrder(2)] PermissionSet Permissions, + [property: MemoryPackOrder(3)] ImmutableHashSet<Guid> ManagedAgentGuids +) { + public bool CheckPermission(Permission permission) { + return Permissions.Check(permission); + } + + public bool HasAccessToAgent(Guid agentGuid) { + return ManagedAgentGuids.Contains(agentGuid) || Permissions.Check(Permission.ManageAllAgents); + } + + public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) { + return Permissions.Check(Permission.ManageAllAgents) ? agentGuids : agentGuids.Intersect(ManagedAgentGuids); + } +} diff --git a/Common/Phantom.Common.Data.Web/Users/Permission.cs b/Common/Phantom.Common.Data.Web/Users/Permission.cs index 6220c89..5a96efe 100644 --- a/Common/Phantom.Common.Data.Web/Users/Permission.cs +++ b/Common/Phantom.Common.Data.Web/Users/Permission.cs @@ -14,6 +14,9 @@ public sealed record Permission(string Id, Permission? Parent) { return Register(id, this); } + public const string ManageAllAgentsPolicy = "Agents.ManageAll"; + public static readonly Permission ManageAllAgents = Register(ManageAllAgentsPolicy); + public const string ViewInstancesPolicy = "Instances.View"; public static readonly Permission ViewInstances = Register(ViewInstancesPolicy); diff --git a/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.Designer.cs b/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.Designer.cs new file mode 100644 index 0000000..4907623 --- /dev/null +++ b/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.Designer.cs @@ -0,0 +1,353 @@ +// <auto-generated /> +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Phantom.Controller.Database; + +#nullable disable + +namespace Phantom.Controller.Database.Postgres.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240407211636_UserAgentAccess")] + partial class UserAgentAccess + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b => + { + b.Property<Guid>("AgentGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("BuildVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("MaxInstances") + .HasColumnType("integer"); + + b.Property<ushort>("MaxMemory") + .HasColumnType("integer"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ProtocolVersion") + .HasColumnType("integer"); + + b.HasKey("AgentGuid"); + + b.ToTable("Agents", "agents"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<JsonDocument>("Data") + .HasColumnType("jsonb"); + + b.Property<string>("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SubjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SubjectType") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid?>("UserGuid") + .HasColumnType("uuid"); + + b.Property<DateTime>("UtcTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserGuid"); + + b.ToTable("AuditLog", "system"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b => + { + b.Property<Guid>("EventGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid?>("AgentGuid") + .HasColumnType("uuid"); + + b.Property<JsonDocument>("Data") + .HasColumnType("jsonb"); + + b.Property<string>("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SubjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SubjectType") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UtcTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventGuid"); + + b.ToTable("EventLog", "system"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b => + { + b.Property<Guid>("InstanceGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid>("AgentGuid") + .HasColumnType("uuid"); + + b.Property<string>("InstanceName") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid>("JavaRuntimeGuid") + .HasColumnType("uuid"); + + b.Property<string>("JvmArguments") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("LaunchAutomatically") + .HasColumnType("boolean"); + + b.Property<ushort>("MemoryAllocation") + .HasColumnType("integer"); + + b.Property<string>("MinecraftServerKind") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("MinecraftVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("RconPort") + .HasColumnType("integer"); + + b.Property<int>("ServerPort") + .HasColumnType("integer"); + + b.HasKey("InstanceGuid"); + + b.ToTable("Instances", "agents"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Permissions", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b => + { + b.Property<Guid>("RoleGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("RoleGuid"); + + b.ToTable("Roles", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b => + { + b.Property<Guid>("RoleGuid") + .HasColumnType("uuid"); + + b.Property<string>("PermissionId") + .HasColumnType("text"); + + b.HasKey("RoleGuid", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("RolePermissions", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b => + { + b.Property<Guid>("UserGuid") + .HasColumnType("uuid"); + + b.Property<Guid>("AgentGuid") + .HasColumnType("uuid"); + + b.HasKey("UserGuid", "AgentGuid"); + + b.HasIndex("AgentGuid"); + + b.ToTable("UserAgentAccess", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b => + { + b.Property<Guid>("UserGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserGuid"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b => + { + b.Property<Guid>("UserGuid") + .HasColumnType("uuid"); + + b.Property<string>("PermissionId") + .HasColumnType("text"); + + b.HasKey("UserGuid", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("UserPermissions", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b => + { + b.Property<Guid>("UserGuid") + .HasColumnType("uuid"); + + b.Property<Guid>("RoleGuid") + .HasColumnType("uuid"); + + b.HasKey("UserGuid", "RoleGuid"); + + b.HasIndex("RoleGuid"); + + b.ToTable("UserRoles", "identity"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserGuid") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null) + .WithMany() + .HasForeignKey("RoleGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null) + .WithMany() + .HasForeignKey("AgentGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role") + .WithMany() + .HasForeignKey("RoleGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.cs b/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.cs new file mode 100644 index 0000000..21f4160 --- /dev/null +++ b/Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Phantom.Controller.Database.Postgres.Migrations +{ + /// <inheritdoc /> + public partial class UserAgentAccess : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserAgentAccess", + schema: "identity", + columns: table => new + { + UserGuid = table.Column<Guid>(type: "uuid", nullable: false), + AgentGuid = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserAgentAccess", x => new { x.UserGuid, x.AgentGuid }); + table.ForeignKey( + name: "FK_UserAgentAccess_Agents_AgentGuid", + column: x => x.AgentGuid, + principalSchema: "agents", + principalTable: "Agents", + principalColumn: "AgentGuid", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserAgentAccess_Users_UserGuid", + column: x => x.UserGuid, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "UserGuid", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserAgentAccess_AgentGuid", + schema: "identity", + table: "UserAgentAccess", + column: "AgentGuid"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserAgentAccess", + schema: "identity"); + } + } +} diff --git a/Controller/Phantom.Controller.Database.Postgres/Migrations/ApplicationDbContextModelSnapshot.cs b/Controller/Phantom.Controller.Database.Postgres/Migrations/ApplicationDbContextModelSnapshot.cs index 63bef7e..549d983 100644 --- a/Controller/Phantom.Controller.Database.Postgres/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Controller/Phantom.Controller.Database.Postgres/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.11") + .HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -204,6 +204,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations b.ToTable("RolePermissions", "identity"); }); + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b => + { + b.Property<Guid>("UserGuid") + .HasColumnType("uuid"); + + b.Property<Guid>("AgentGuid") + .HasColumnType("uuid"); + + b.HasKey("UserGuid", "AgentGuid"); + + b.HasIndex("AgentGuid"); + + b.ToTable("UserAgentAccess", "identity"); + }); + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b => { b.Property<Guid>("UserGuid") @@ -281,6 +296,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations .IsRequired(); }); + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b => + { + b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null) + .WithMany() + .HasForeignKey("AgentGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserGuid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b => { b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null) diff --git a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs index f3c313a..9698200 100644 --- a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs +++ b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs @@ -20,7 +20,8 @@ public class ApplicationDbContext : DbContext { public DbSet<UserRoleEntity> UserRoles { get; init; } = null!; public DbSet<UserPermissionEntity> UserPermissions { get; init; } = null!; public DbSet<RolePermissionEntity> RolePermissions { get; init; } = null!; - + public DbSet<UserAgentAccessEntity> UserAgentAccess { get; init; } = null!; + public DbSet<AgentEntity> Agents { get; init; } = null!; public DbSet<InstanceEntity> Instances { get; init; } = null!; public DbSet<AuditLogEntity> AuditLog { get; init; } = null!; @@ -62,6 +63,12 @@ public class ApplicationDbContext : DbContext { b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade); b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade); }); + + builder.Entity<UserAgentAccessEntity>(static b => { + b.HasKey(static e => new { UserId = e.UserGuid, AgentId = e.AgentGuid }); + b.HasOne<UserEntity>().WithMany().HasForeignKey(static e => e.UserGuid).IsRequired().OnDelete(DeleteBehavior.Cascade); + b.HasOne<AgentEntity>().WithMany().HasForeignKey(static e => e.AgentGuid).IsRequired().OnDelete(DeleteBehavior.Cascade); + }); } protected override void ConfigureConventions(ModelConfigurationBuilder builder) { diff --git a/Controller/Phantom.Controller.Database/Entities/UserAgentAccessEntity.cs b/Controller/Phantom.Controller.Database/Entities/UserAgentAccessEntity.cs new file mode 100644 index 0000000..297efd2 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Entities/UserAgentAccessEntity.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Phantom.Controller.Database.Entities; + +[Table("UserAgentAccess", Schema = "identity")] +public sealed class UserAgentAccessEntity { + public Guid UserGuid { get; init; } + public Guid AgentGuid { get; init; } + + public UserAgentAccessEntity(Guid userGuid, Guid agentGuid) { + UserGuid = userGuid; + AgentGuid = agentGuid; + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs index b861511..7bd0280 100644 --- a/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs +++ b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs @@ -17,11 +17,12 @@ public sealed class EventLogRepository { db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra)); } - public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { + public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(ImmutableHashSet<Guid> agentGuids, int count, CancellationToken cancellationToken) { return db.Ctx .EventLog .AsQueryable() .OrderByDescending(static entity => entity.UtcTime) + .Where(entity => entity.AgentGuid == null || agentGuids.Contains(entity.AgentGuid.Value)) .Take(count) .AsAsyncEnumerable() .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString())) diff --git a/Controller/Phantom.Controller.Database/Repositories/PermissionRepository.cs b/Controller/Phantom.Controller.Database/Repositories/PermissionRepository.cs index 463f755..ffd8eff 100644 --- a/Controller/Phantom.Controller.Database/Repositories/PermissionRepository.cs +++ b/Controller/Phantom.Controller.Database/Repositories/PermissionRepository.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; using Phantom.Common.Data.Web.Users; using Phantom.Controller.Database.Entities; using Phantom.Utils.Collections; @@ -23,4 +24,12 @@ public sealed class PermissionRepository { return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync()); } + + public Task<ImmutableHashSet<Guid>> GetManagedAgentGuids(UserEntity user) { + return db.Ctx.UserAgentAccess + .Where(ua => ua.UserGuid == user.UserGuid) + .Select(static ua => ua.AgentGuid) + .AsAsyncEnumerable() + .ToImmutableSetAsync(); + } } diff --git a/Controller/Phantom.Controller.Services/Agents/AgentActor.cs b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs index 6dd6760..46c17fa 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentActor.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs @@ -10,14 +10,12 @@ using Phantom.Common.Data.Replies; using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Minecraft; -using Phantom.Common.Data.Web.Users; using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Controller.Database; using Phantom.Controller.Database.Entities; using Phantom.Controller.Minecraft; using Phantom.Controller.Services.Instances; -using Phantom.Controller.Services.Users.Sessions; using Phantom.Utils.Actor; using Phantom.Utils.Actor.Mailbox; using Phantom.Utils.Actor.Tasks; @@ -34,7 +32,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12); - public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, UserLoginManager UserLoginManager, IDbContextProvider DbProvider, CancellationToken CancellationToken); + public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, IDbContextProvider DbProvider, CancellationToken CancellationToken); public static Props<ICommand> Factory(Init init) { return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); @@ -42,7 +40,6 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { private readonly ControllerState controllerState; private readonly MinecraftVersions minecraftVersions; - private readonly UserLoginManager userLoginManager; private readonly IDbContextProvider dbProvider; private readonly CancellationToken cancellationToken; @@ -79,7 +76,6 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { private AgentActor(Init init) { this.controllerState = init.ControllerState; this.minecraftVersions = init.MinecraftVersions; - this.userLoginManager = init.UserLoginManager; this.dbProvider = init.DbProvider; this.cancellationToken = init.CancellationToken; @@ -98,11 +94,11 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { Receive<NotifyIsAliveCommand>(NotifyIsAlive); Receive<UpdateStatsCommand>(UpdateStats); Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes); - ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstance); + ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance); Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus); - ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, UserInstanceActionFailure>>(LaunchInstance); - ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, UserInstanceActionFailure>>(StopInstance); - ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(SendMinecraftCommand); + ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); + ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); + ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand); Receive<ReceiveInstanceDataCommand>(ReceiveInstanceData); } @@ -150,21 +146,13 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { } } - private async Task<Result<TReply, UserInstanceActionFailure>> RequestInstance<TCommand, TReply>(ImmutableArray<byte> authToken, Guid instanceGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : InstanceActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> { - var loggedInUser = userLoginManager.GetLoggedInUser(authToken); - if (!loggedInUser.CheckPermission(Permission.ControlInstances)) { - return (UserInstanceActionFailure) UserActionFailure.NotAuthorized; - } - - var command = commandFactoryFromLoggedInUserGuid(loggedInUser.Guid!.Value); - + private async Task<Result<TReply, InstanceActionFailure>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> { if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) { - var result = await instance.Request(command, cancellationToken); - return result.MapError(static error => (UserInstanceActionFailure) error); + return await instance.Request(command, cancellationToken); } else { Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid); - return (UserInstanceActionFailure) InstanceActionFailure.InstanceDoesNotExist; + return InstanceActionFailure.InstanceDoesNotExist; } } @@ -195,15 +183,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand; - public sealed record CreateOrUpdateInstanceCommand(ImmutableArray<byte> AuthToken, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>; + public sealed record CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>; public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand; - public sealed record LaunchInstanceCommand(ImmutableArray<byte> AuthToken, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, UserInstanceActionFailure>>; + public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>; - public sealed record StopInstanceCommand(ImmutableArray<byte> AuthToken, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, UserInstanceActionFailure>>; + public sealed record StopInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>; - public sealed record SendCommandToInstanceCommand(ImmutableArray<byte> AuthToken, Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, UserInstanceActionFailure>>; + public sealed record SendCommandToInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>; public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead; @@ -292,30 +280,25 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); } - private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance(CreateOrUpdateInstanceCommand command) { - var loggedInUser = userLoginManager.GetLoggedInUser(command.AuthToken); - if (!loggedInUser.CheckPermission(Permission.CreateInstances)) { - return Task.FromResult<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>((UserInstanceActionFailure) UserActionFailure.NotAuthorized); - } - + private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance(CreateOrUpdateInstanceCommand command) { var instanceConfiguration = command.Configuration; if (string.IsNullOrWhiteSpace(instanceConfiguration.InstanceName)) { - return Task.FromResult<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty); + return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty); } if (instanceConfiguration.MemoryAllocation <= RamAllocationUnits.Zero) { - return Task.FromResult<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero); + return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero); } return minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken) - .ContinueOnActor(CreateOrUpdateInstance1, loggedInUser.Guid!.Value, command) + .ContinueOnActor(CreateOrUpdateInstance1, command) .Unwrap(); } - private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, Guid loggedInUserGuid, CreateOrUpdateInstanceCommand command) { + private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, CreateOrUpdateInstanceCommand command) { if (serverExecutableInfo == null) { - return Task.FromResult<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound); + return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound); } var instanceConfiguration = command.Configuration; @@ -325,13 +308,13 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration)); } - var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(loggedInUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance); + var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.LoggedInUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance); return instanceActorRef.Request(configureInstanceCommand, cancellationToken) .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand); } - private Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure> CreateOrUpdateInstance2(Result<ConfigureInstanceResult, InstanceActionFailure> result, InstanceActor.ConfigureInstanceCommand command) { + private Result<CreateOrUpdateInstanceResult, InstanceActionFailure> CreateOrUpdateInstance2(Result<ConfigureInstanceResult, InstanceActionFailure> result, InstanceActor.ConfigureInstanceCommand command) { var instanceGuid = command.InstanceGuid; var instanceName = command.Configuration.InstanceName; var isCreating = command.IsCreatingInstance; @@ -359,16 +342,16 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status)); } - private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) { - return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.AuthToken, command.InstanceGuid, static loggedInUserGuid => new InstanceActor.LaunchInstanceCommand(loggedInUserGuid)); + private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) { + return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid)); } - private Task<Result<StopInstanceResult, UserInstanceActionFailure>> StopInstance(StopInstanceCommand command) { - return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.AuthToken, command.InstanceGuid, loggedInUserGuid => new InstanceActor.StopInstanceCommand(loggedInUserGuid, command.StopStrategy)); + private Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(StopInstanceCommand command) { + return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.LoggedInUserGuid, command.StopStrategy)); } - private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) { - return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.AuthToken, command.InstanceGuid, loggedInUserGuid => new InstanceActor.SendCommandToInstanceCommand(loggedInUserGuid, command.Command)); + private Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) { + return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.LoggedInUserGuid, command.Command)); } private void ReceiveInstanceData(ReceiveInstanceDataCommand command) { diff --git a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs index 168d32f..af21c8b 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.Immutable; using Akka.Actor; using Phantom.Common.Data; using Phantom.Common.Data.Agent; @@ -44,7 +45,7 @@ sealed class AgentManager { } private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, AgentConfiguration agentConfiguration) { - var init = new AgentActor.Init(agentGuid, agentConfiguration, controllerState, minecraftVersions, userLoginManager, dbProvider, cancellationToken); + var init = new AgentActor.Init(agentGuid, agentConfiguration, controllerState, minecraftVersions, dbProvider, cancellationToken); var name = "Agent:" + agentGuid; return actorSystem.ActorOf(AgentActor.Factory(init), name); } @@ -87,7 +88,18 @@ sealed class AgentManager { } } - public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Guid agentGuid, TCommand command) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, UserInstanceActionFailure>> { - return agentsByGuid.TryGetValue(agentGuid, out var agent) ? await agent.Request(command, cancellationToken) : (UserInstanceActionFailure) InstanceActionFailure.AgentDoesNotExist; + public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Permission requiredPermission, ImmutableArray<byte> authToken, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> { + var loggedInUser = userLoginManager.GetLoggedInUser(authToken); + if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) { + return (UserInstanceActionFailure) UserActionFailure.NotAuthorized; + } + + if (!agentsByGuid.TryGetValue(agentGuid, out var agent)) { + return (UserInstanceActionFailure) InstanceActionFailure.AgentDoesNotExist; + } + + var command = commandFactoryFromLoggedInUserGuid(loggedInUser.Guid!.Value); + var result = await agent.Request(command, cancellationToken); + return result.MapError(static error => (UserInstanceActionFailure) error); } } diff --git a/Controller/Phantom.Controller.Services/ControllerServices.cs b/Controller/Phantom.Controller.Services/ControllerServices.cs index 91dfbaa..7c62ef7 100644 --- a/Controller/Phantom.Controller.Services/ControllerServices.cs +++ b/Controller/Phantom.Controller.Services/ControllerServices.cs @@ -64,7 +64,7 @@ public sealed class ControllerServices : IDisposable { this.InstanceLogManager = new InstanceLogManager(); this.AuditLogManager = new AuditLogManager(dbProvider); - this.EventLogManager = new EventLogManager(ActorSystem, dbProvider, shutdownCancellationToken); + this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken); this.AgentRegistrationHandler = new AgentRegistrationHandler(AgentManager, InstanceLogManager, EventLogManager); this.WebRegistrationHandler = new WebRegistrationHandler(webAuthToken, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager); diff --git a/Controller/Phantom.Controller.Services/Events/EventLogManager.cs b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs index 2e129e9..73361da 100644 --- a/Controller/Phantom.Controller.Services/Events/EventLogManager.cs +++ b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs @@ -11,11 +11,13 @@ using Phantom.Utils.Actor; namespace Phantom.Controller.Services.Events; sealed partial class EventLogManager { + private readonly ControllerState controllerState; private readonly ActorRef<EventLogDatabaseStorageActor.ICommand> databaseStorageActor; private readonly IDbContextProvider dbProvider; private readonly CancellationToken cancellationToken; - public EventLogManager(IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) { + public EventLogManager(ControllerState controllerState, IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) { + this.controllerState = controllerState; this.databaseStorageActor = actorSystem.ActorOf(EventLogDatabaseStorageActor.Factory(new EventLogDatabaseStorageActor.Init(dbProvider, cancellationToken)), "EventLogDatabaseStorage"); this.dbProvider = dbProvider; this.cancellationToken = cancellationToken; @@ -30,7 +32,9 @@ sealed partial class EventLogManager { return UserActionFailure.NotAuthorized; } + var accessibleAgentGuids = loggedInUser.FilterAccessibleAgentGuids(controllerState.AgentsByGuid.Keys.ToImmutableHashSet()); + await using var db = dbProvider.Lazy(); - return await new EventLogRepository(db).GetMostRecentItems(count, cancellationToken); + return await new EventLogRepository(db).GetMostRecentItems(accessibleAgentGuids, count, cancellationToken); } } diff --git a/Controller/Phantom.Controller.Services/Rpc/WebMessageHandlerActor.cs b/Controller/Phantom.Controller.Services/Rpc/WebMessageHandlerActor.cs index 29d2cd8..091671a 100644 --- a/Controller/Phantom.Controller.Services/Rpc/WebMessageHandlerActor.cs +++ b/Controller/Phantom.Controller.Services/Rpc/WebMessageHandlerActor.cs @@ -141,19 +141,39 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> { } private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) { - return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(message.Configuration.AgentGuid, new AgentActor.CreateOrUpdateInstanceCommand(message.AuthToken, message.InstanceGuid, message.Configuration)); + return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>( + Permission.CreateInstances, + message.AuthToken, + message.Configuration.AgentGuid, + loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration) + ); } private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) { - return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(message.AgentGuid, new AgentActor.LaunchInstanceCommand(message.AuthToken, message.InstanceGuid)); + return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>( + Permission.ControlInstances, + message.AuthToken, + message.AgentGuid, + loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid) + ); } private Task<Result<StopInstanceResult, UserInstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) { - return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(message.AgentGuid, new AgentActor.StopInstanceCommand(message.AuthToken, message.InstanceGuid, message.StopStrategy)); + return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>( + Permission.ControlInstances, + message.AuthToken, + message.AgentGuid, + loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy) + ); } private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { - return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(message.AgentGuid, new AgentActor.SendCommandToInstanceCommand(message.AuthToken, message.InstanceGuid, message.Command)); + return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>( + Permission.ControlInstances, + message.AuthToken, + message.AgentGuid, + loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command) + ); } private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) { diff --git a/Controller/Phantom.Controller.Services/Users/Sessions/AuthenticatedUserCache.cs b/Controller/Phantom.Controller.Services/Users/Sessions/AuthenticatedUserCache.cs index 7a77c4c..c850cf0 100644 --- a/Controller/Phantom.Controller.Services/Users/Sessions/AuthenticatedUserCache.cs +++ b/Controller/Phantom.Controller.Services/Users/Sessions/AuthenticatedUserCache.cs @@ -14,9 +14,12 @@ sealed class AuthenticatedUserCache { } public async Task<AuthenticatedUserInfo?> Update(UserEntity user, ILazyDbContext db) { + var permissionRepository = new PermissionRepository(db); + var userPermissions = await permissionRepository.GetAllUserPermissions(user); + var userManagedAgentGuids = await permissionRepository.GetManagedAgentGuids(user); + var userGuid = user.UserGuid; - var userPermissions = await new PermissionRepository(db).GetAllUserPermissions(user); - var userInfo = new AuthenticatedUserInfo(userGuid, user.Name, userPermissions); + var userInfo = new AuthenticatedUserInfo(userGuid, user.Name, userPermissions, userManagedAgentGuids); return authenticatedUsersByGuid[userGuid] = userInfo; } diff --git a/Controller/Phantom.Controller.Services/Users/Sessions/LoggedInUser.cs b/Controller/Phantom.Controller.Services/Users/Sessions/LoggedInUser.cs index 583c03b..043ac70 100644 --- a/Controller/Phantom.Controller.Services/Users/Sessions/LoggedInUser.cs +++ b/Controller/Phantom.Controller.Services/Users/Sessions/LoggedInUser.cs @@ -1,4 +1,5 @@ -using Phantom.Common.Data.Web.Users; +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Users; namespace Phantom.Controller.Services.Users.Sessions; @@ -6,6 +7,14 @@ readonly record struct LoggedInUser(AuthenticatedUserInfo? AuthenticatedUserInfo public Guid? Guid => AuthenticatedUserInfo?.Guid; public bool CheckPermission(Permission permission) { - return AuthenticatedUserInfo != null && AuthenticatedUserInfo.Permissions.Check(permission); + return AuthenticatedUserInfo is {} info && info.CheckPermission(permission); + } + + public bool HasAccessToAgent(Guid agentGuid) { + return AuthenticatedUserInfo is {} info && info.HasAccessToAgent(agentGuid); + } + + public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) { + return AuthenticatedUserInfo is {} info ? info.FilterAccessibleAgentGuids(agentGuids) : ImmutableHashSet<Guid>.Empty; } } diff --git a/Web/Phantom.Web.Services/Agents/AgentManager.cs b/Web/Phantom.Web.Services/Agents/AgentManager.cs index 4ab2a79..e203405 100644 --- a/Web/Phantom.Web.Services/Agents/AgentManager.cs +++ b/Web/Phantom.Web.Services/Agents/AgentManager.cs @@ -2,6 +2,7 @@ using Phantom.Common.Data.Web.Agent; using Phantom.Utils.Events; using Phantom.Utils.Logging; +using Phantom.Web.Services.Authentication; namespace Phantom.Web.Services.Agents; @@ -18,7 +19,13 @@ public sealed class AgentManager { return agents.Value; } - public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid() { - return agents.Value.ToImmutableDictionary(static agent => agent.AgentGuid); + public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) { + if (authenticatedUser == null) { + return ImmutableDictionary<Guid, Agent>.Empty; + } + + return agents.Value + .Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) + .ToImmutableDictionary(static agent => agent.AgentGuid); } } diff --git a/Web/Phantom.Web.Services/Authentication/AuthenticatedUser.cs b/Web/Phantom.Web.Services/Authentication/AuthenticatedUser.cs index 8f024b6..7554775 100644 --- a/Web/Phantom.Web.Services/Authentication/AuthenticatedUser.cs +++ b/Web/Phantom.Web.Services/Authentication/AuthenticatedUser.cs @@ -3,8 +3,4 @@ using Phantom.Common.Data.Web.Users; namespace Phantom.Web.Services.Authentication; -public sealed record AuthenticatedUser(AuthenticatedUserInfo Info, ImmutableArray<byte> Token) { - public bool CheckPermission(Permission permission) { - return Info.Permissions.Check(permission); - } -} +public sealed record AuthenticatedUser(AuthenticatedUserInfo Info, ImmutableArray<byte> Token); diff --git a/Web/Phantom.Web.Services/Events/EventLogManager.cs b/Web/Phantom.Web.Services/Events/EventLogManager.cs index 0bb5f55..79eb8aa 100644 --- a/Web/Phantom.Web.Services/Events/EventLogManager.cs +++ b/Web/Phantom.Web.Services/Events/EventLogManager.cs @@ -16,7 +16,7 @@ public sealed class EventLogManager { } public async Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetMostRecentItems(AuthenticatedUser? authenticatedUser, int count, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.ViewEvents)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ViewEvents)) { var message = new GetEventLogMessage(authenticatedUser.Token, count); return await controllerConnection.Send<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(message, cancellationToken); } diff --git a/Web/Phantom.Web.Services/Instances/InstanceManager.cs b/Web/Phantom.Web.Services/Instances/InstanceManager.cs index 98f1633..5f1ddfc 100644 --- a/Web/Phantom.Web.Services/Instances/InstanceManager.cs +++ b/Web/Phantom.Web.Services/Instances/InstanceManager.cs @@ -33,12 +33,17 @@ public sealed class InstanceManager { return instances.Value; } - public Instance? GetByGuid(Guid instanceGuid) { - return instances.Value.GetValueOrDefault(instanceGuid); + public Instance? GetByGuid(AuthenticatedUser? authenticatedUser, Guid instanceGuid) { + if (authenticatedUser == null) { + return null; + } + + var instance = instances.Value.GetValueOrDefault(instanceGuid); + return instance != null && authenticatedUser.Info.HasAccessToAgent(instance.Configuration.AgentGuid) ? instance : null; } public async Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance(AuthenticatedUser? authenticatedUser, Guid instanceGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.CreateInstances)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.CreateInstances)) { var message = new CreateOrUpdateInstanceMessage(authenticatedUser.Token, instanceGuid, configuration); return await controllerConnection.Send<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(message, cancellationToken); } @@ -48,7 +53,7 @@ public sealed class InstanceManager { } public async Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> LaunchInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.ControlInstances)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) { var message = new LaunchInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid); return await controllerConnection.Send<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(message, cancellationToken); } @@ -58,7 +63,7 @@ public sealed class InstanceManager { } public async Task<Result<StopInstanceResult, UserInstanceActionFailure>> StopInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.ControlInstances)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) { var message = new StopInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid, stopStrategy); return await controllerConnection.Send<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(message, cancellationToken); } @@ -68,7 +73,7 @@ public sealed class InstanceManager { } public async Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> SendCommandToInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.ControlInstances)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) { var message = new SendCommandToInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid, command); return await controllerConnection.Send<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(message, cancellationToken); } diff --git a/Web/Phantom.Web.Services/Users/AuditLogManager.cs b/Web/Phantom.Web.Services/Users/AuditLogManager.cs index ff20c4c..9f89b25 100644 --- a/Web/Phantom.Web.Services/Users/AuditLogManager.cs +++ b/Web/Phantom.Web.Services/Users/AuditLogManager.cs @@ -16,7 +16,7 @@ public sealed class AuditLogManager { } public async Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetMostRecentItems(AuthenticatedUser? authenticatedUser, int count, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.ViewAudit)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ViewAudit)) { var message = new GetAuditLogMessage(authenticatedUser.Token, count); return await controllerConnection.Send<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(message, cancellationToken); } diff --git a/Web/Phantom.Web.Services/Users/UserManager.cs b/Web/Phantom.Web.Services/Users/UserManager.cs index 30edf57..bcb0672 100644 --- a/Web/Phantom.Web.Services/Users/UserManager.cs +++ b/Web/Phantom.Web.Services/Users/UserManager.cs @@ -19,7 +19,7 @@ public sealed class UserManager { } public async Task<Result<CreateUserResult, UserActionFailure>> Create(AuthenticatedUser? authenticatedUser, string username, string password, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.EditUsers)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) { return await controllerConnection.Send<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(new CreateUserMessage(authenticatedUser.Token, username, password), cancellationToken); } else { @@ -28,7 +28,7 @@ public sealed class UserManager { } public async Task<Result<DeleteUserResult, UserActionFailure>> DeleteByGuid(AuthenticatedUser? authenticatedUser, Guid userGuid, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.EditUsers)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) { return await controllerConnection.Send<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(new DeleteUserMessage(authenticatedUser.Token, userGuid), cancellationToken); } else { diff --git a/Web/Phantom.Web.Services/Users/UserRoleManager.cs b/Web/Phantom.Web.Services/Users/UserRoleManager.cs index 2fd20b1..6e86c04 100644 --- a/Web/Phantom.Web.Services/Users/UserRoleManager.cs +++ b/Web/Phantom.Web.Services/Users/UserRoleManager.cs @@ -23,7 +23,7 @@ public sealed class UserRoleManager { } public async Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(AuthenticatedUser? authenticatedUser, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids, CancellationToken cancellationToken) { - if (authenticatedUser != null && authenticatedUser.CheckPermission(Permission.EditUsers)) { + if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) { return await controllerConnection.Send<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(new ChangeUserRolesMessage(authenticatedUser.Token, subjectUserGuid, addToRoleGuids, removeFromRoleGuids), cancellationToken); } else { diff --git a/Web/Phantom.Web/Pages/Agents.razor b/Web/Phantom.Web/Pages/Agents.razor index 5c273f8..d7bf359 100644 --- a/Web/Phantom.Web/Pages/Agents.razor +++ b/Web/Phantom.Web/Pages/Agents.razor @@ -1,4 +1,5 @@ @page "/agents" +@using System.Collections.Immutable @using Phantom.Common.Data.Web.Agent @using Phantom.Utils.Collections @using Phantom.Web.Services.Agents @@ -46,33 +47,45 @@ <Cell class="fw-semibold text-center text-success">Online</Cell> <Cell class="text-end">-</Cell> break; + case AgentIsOffline: <Cell class="fw-semibold text-center">Offline</Cell> <Cell class="text-end">N/A</Cell> break; + case AgentIsDisconnected status: <Cell class="fw-semibold text-center">Offline</Cell> <Cell class="text-end"> <TimeWithOffset Time="status.LastPingTime" /> </Cell> break; + default: <Cell class="fw-semibold text-center">N/A</Cell> break; } </ItemRow> <NoItemsRow> - No agents registered. + No agents found. </NoItemsRow> </Table> @code { - private readonly TableData<Agent, Guid> agentTable = new(); + private TableData<Agent, Guid>? agentTable; + + protected override async Task OnInitializedAsync() { + var authenticatedUser = await GetAuthenticatedUser(); + if (authenticatedUser == null) { + return; + } - protected override void OnInitialized() { AgentManager.AgentsChanged.Subscribe(this, agents => { - var sortedAgents = agents.Sort(static (a1, a2) => a1.Configuration.AgentName.CompareTo(a2.Configuration.AgentName)); + var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) + .OrderBy(static agent => agent.Configuration.AgentName) + .ToImmutableArray(); + + agentTable ??= new TableData<Agent, Guid>(); agentTable.UpdateFrom(sortedAgents, static agent => agent.AgentGuid, static agent => agent, static (agent, _) => agent); InvokeAsync(StateHasChanged); }); diff --git a/Web/Phantom.Web/Pages/InstanceDetail.razor b/Web/Phantom.Web/Pages/InstanceDetail.razor index d075087..54b51f9 100644 --- a/Web/Phantom.Web/Pages/InstanceDetail.razor +++ b/Web/Phantom.Web/Pages/InstanceDetail.razor @@ -7,9 +7,15 @@ @using Phantom.Common.Data.Instance @using Phantom.Web.Services.Instances @using Phantom.Web.Services.Authorization -@inherits Phantom.Web.Components.PhantomComponent +@inherits PhantomComponent @inject InstanceManager InstanceManager +@if (isLoading) { + <h1>Instance</h1> + <p>Loading...</p> + return; +} + @if (Instance == null) { <h1>Instance Not Found</h1> <p>Return to <a href="instances">all instances</a>.</p> @@ -54,19 +60,25 @@ [Parameter] public Guid InstanceGuid { get; init; } + private Instance? Instance { get; set; } + private bool isLoading = true; + private string? lastError = null; private bool isLaunchingInstance = false; - private Instance? Instance { get; set; } + protected override async Task OnInitializedAsync() { + Instance = InstanceManager.GetByGuid(await GetAuthenticatedUser(), InstanceGuid); + isLoading = false; - protected override void OnInitialized() { - InstanceManager.InstancesChanged.Subscribe(this, instances => { - var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null; - if (newInstance != Instance) { - Instance = newInstance; - InvokeAsync(StateHasChanged); - } - }); + if (Instance != null) { + InstanceManager.InstancesChanged.Subscribe(this, instances => { + var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null; + if (newInstance != Instance) { + Instance = newInstance; + InvokeAsync(StateHasChanged); + } + }); + } } private async Task LaunchInstance() { diff --git a/Web/Phantom.Web/Pages/InstanceEdit.razor b/Web/Phantom.Web/Pages/InstanceEdit.razor index 20e95b6..f67f8e9 100644 --- a/Web/Phantom.Web/Pages/InstanceEdit.razor +++ b/Web/Phantom.Web/Pages/InstanceEdit.razor @@ -6,14 +6,20 @@ @inherits PhantomComponent @inject InstanceManager InstanceManager +@if (isLoading) { + <h1>Edit Instance</h1> + <p>Loading...</p> + return; +} + @if (Instance == null) { <h1>Instance Not Found</h1> <p>Return to <a href="instances">all instances</a>.</p> + return; } -else { - <h1>Edit Instance: @Instance.Configuration.InstanceName</h1> - <InstanceAddOrEditForm EditedInstance="Instance" /> -} + +<h1>Edit Instance: @Instance.Configuration.InstanceName</h1> +<InstanceAddOrEditForm EditedInstance="Instance" /> @code { @@ -21,9 +27,11 @@ else { public Guid InstanceGuid { get; init; } private Instance? Instance { get; set; } + private bool isLoading = true; - protected override void OnInitialized() { - Instance = InstanceManager.GetByGuid(InstanceGuid); + protected override async Task OnInitializedAsync() { + Instance = InstanceManager.GetByGuid(await GetAuthenticatedUser(), InstanceGuid); + isLoading = false; } } diff --git a/Web/Phantom.Web/Pages/Instances.razor b/Web/Phantom.Web/Pages/Instances.razor index cdb2a5a..0e4bdbb 100644 --- a/Web/Phantom.Web/Pages/Instances.razor +++ b/Web/Phantom.Web/Pages/Instances.razor @@ -69,9 +69,18 @@ this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName); InvokeAsync(StateHasChanged); }); + } + + protected override async Task OnInitializedAsync() { + var authenticatedUser = await GetAuthenticatedUser(); + if (authenticatedUser == null) { + instances = ImmutableArray<Instance>.Empty; + return; + } InstanceManager.InstancesChanged.Subscribe(this, instances => { this.instances = instances.Values + .Where(instance => authenticatedUser.Info.HasAccessToAgent(instance.Configuration.AgentGuid)) .OrderBy(instance => agentNamesByGuid.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty) .ThenBy(static instance => instance.Configuration.InstanceName) .ToImmutableArray(); diff --git a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor index 97a676e..5d970d3 100644 --- a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor +++ b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor @@ -278,10 +278,11 @@ } protected override async Task OnInitializedAsync() { + var authenticatedUser = await GetAuthenticatedUser(); var agentJavaRuntimesTask = ControllerConnection.Send<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(new GetAgentJavaRuntimesMessage(), TimeSpan.FromSeconds(30)); var minecraftVersionsTask = ControllerConnection.Send<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(new GetMinecraftVersionsMessage(), TimeSpan.FromSeconds(30)); - allAgentsByGuid = AgentManager.ToDictionaryByGuid(); + allAgentsByGuid = AgentManager.ToDictionaryByGuid(authenticatedUser); allAgentJavaRuntimes = await agentJavaRuntimesTask; allMinecraftVersions = await minecraftVersionsTask;