mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-10-31 02:17:16 +01:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			wip-forge
			...
			15d45fe1a3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 15d45fe1a3 | |||
| 0b8a870d10 | |||
| 16888c9b10 | |||
| bfefb9063b | 
| @@ -1,9 +1,10 @@ | ||||
| <component name="ProjectRunConfigurationManager"> | ||||
|   <configuration default="false" name="Controller + Agent x3" type="CompoundRunConfigurationType"> | ||||
|   <configuration default="false" name="Controller + Web + Agent x3" type="CompoundRunConfigurationType"> | ||||
|     <toRun name="Agent 1" type="DotNetProject" /> | ||||
|     <toRun name="Agent 2" type="DotNetProject" /> | ||||
|     <toRun name="Agent 3" type="DotNetProject" /> | ||||
|     <toRun name="Controller" type="DotNetProject" /> | ||||
|     <toRun name="Web" type="DotNetProject" /> | ||||
|     <method v="2" /> | ||||
|   </configuration> | ||||
| </component> | ||||
| @@ -1,7 +1,8 @@ | ||||
| <component name="ProjectRunConfigurationManager"> | ||||
|   <configuration default="false" name="Controller + Agent" type="CompoundRunConfigurationType"> | ||||
|   <configuration default="false" name="Controller + Web + Agent" type="CompoundRunConfigurationType"> | ||||
|     <toRun name="Agent 1" type="DotNetProject" /> | ||||
|     <toRun name="Controller" type="DotNetProject" /> | ||||
|     <toRun name="Web" type="DotNetProject" /> | ||||
|     <method v="2" /> | ||||
|   </configuration> | ||||
| </component> | ||||
							
								
								
									
										26
									
								
								.run/Web.run.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.run/Web.run.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| <component name="ProjectRunConfigurationManager"> | ||||
|   <configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project"> | ||||
|     <option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" /> | ||||
|     <option name="PROGRAM_PARAMETERS" value="" /> | ||||
|     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" /> | ||||
|     <option name="PASS_PARENT_ENVS" value="1" /> | ||||
|     <envs> | ||||
|       <env name="ASPNETCORE_ENVIRONMENT" value="Development" /> | ||||
|       <env name="CONTROLLER_HOST" value="localhost" /> | ||||
|       <env name="WEB_KEY" value="BMNHM9RRPMCBBY29D9XHS6KBKZSRY7F5XFN27YZX96XXWJC2NM2D6YRHM9PZN9JGQGCSJ6FMB2GGZ" /> | ||||
|       <env name="WEB_SERVER_HOST" value="localhost" /> | ||||
|     </envs> | ||||
|     <option name="USE_EXTERNAL_CONSOLE" value="0" /> | ||||
|     <option name="USE_MONO" value="0" /> | ||||
|     <option name="RUNTIME_ARGUMENTS" value="" /> | ||||
|     <option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" /> | ||||
|     <option name="PROJECT_EXE_PATH_TRACKING" value="1" /> | ||||
|     <option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> | ||||
|     <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> | ||||
|     <option name="PROJECT_KIND" value="DotNetCore" /> | ||||
|     <option name="PROJECT_TFM" value="net8.0" /> | ||||
|     <method v="2"> | ||||
|       <option name="Build" /> | ||||
|     </method> | ||||
|   </configuration> | ||||
| </component> | ||||
| @@ -1,5 +1,5 @@ | ||||
| using NetMQ; | ||||
| using Phantom.Common.Data.Agent; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Cryptography; | ||||
| using Phantom.Utils.IO; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Database.Enums; | ||||
| namespace Phantom.Common.Data.Web.AuditLog; | ||||
| 
 | ||||
| public enum AuditLogEventType { | ||||
| 	AdministratorUserCreated, | ||||
| @@ -6,6 +6,7 @@ public enum AuditLogEventType { | ||||
| 	UserLoggedIn, | ||||
| 	UserLoggedOut, | ||||
| 	UserCreated, | ||||
| 	UserPasswordChanged, | ||||
| 	UserRolesChanged, | ||||
| 	UserDeleted, | ||||
| 	InstanceCreated, | ||||
| @@ -15,13 +16,14 @@ public enum AuditLogEventType { | ||||
| 	InstanceCommandExecuted | ||||
| } | ||||
| 
 | ||||
| static class AuditLogEventTypeExtensions { | ||||
| public static class AuditLogEventTypeExtensions { | ||||
| 	private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () { | ||||
| 		{ AuditLogEventType.AdministratorUserCreated,  AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserLoggedIn,              AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserLoggedOut,             AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserCreated,               AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserPasswordChanged,       AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserRolesChanged,          AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.UserDeleted,               AuditLogSubjectType.User }, | ||||
| 		{ AuditLogEventType.InstanceCreated,           AuditLogSubjectType.Instance }, | ||||
| @@ -39,7 +41,7 @@ static class AuditLogEventTypeExtensions { | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	internal static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) { | ||||
| 	public static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) { | ||||
| 		return SubjectTypes[type]; | ||||
| 	} | ||||
| } | ||||
| @@ -1,6 +1,5 @@ | ||||
| using System.Text.Json; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Audit; | ||||
| namespace Phantom.Common.Data.Web.AuditLog; | ||||
| 
 | ||||
| public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data); | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Database.Enums; | ||||
| namespace Phantom.Common.Data.Web.AuditLog; | ||||
| 
 | ||||
| public enum AuditLogSubjectType { | ||||
| 	User, | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Database.Enums; | ||||
| namespace Phantom.Common.Data.Web.EventLog; | ||||
| 
 | ||||
| public enum EventLogEventType { | ||||
| 	InstanceLaunchSucceded, | ||||
| @@ -10,7 +10,7 @@ public enum EventLogEventType { | ||||
| 	InstanceBackupFailed, | ||||
| } | ||||
| 
 | ||||
| static class EventLogEventTypeExtensions { | ||||
| public static class EventLogEventTypeExtensions { | ||||
| 	private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () { | ||||
| 		{ EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance }, | ||||
| 		{ EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance }, | ||||
| @@ -1,6 +1,5 @@ | ||||
| using System.Text.Json; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Events; | ||||
| namespace Phantom.Common.Data.Web.EventLog; | ||||
| 
 | ||||
| public sealed record EventLogItem(DateTime UtcTime, Guid? AgentGuid, EventLogEventType EventType, EventLogSubjectType SubjectType, string SubjectId, JsonDocument? Data); | ||||
| @@ -0,0 +1,5 @@ | ||||
| namespace Phantom.Common.Data.Web.EventLog; | ||||
|  | ||||
| public enum EventLogSubjectType { | ||||
| 	Instance | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| using System.Collections.Immutable; | ||||
| 
 | ||||
| namespace Phantom.Controller.Minecraft; | ||||
| namespace Phantom.Common.Data.Web.Minecraft; | ||||
| 
 | ||||
| public static class JvmArgumentsHelper { | ||||
| 	public static ImmutableArray<string> Split(string arguments) { | ||||
| @@ -0,0 +1,13 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|    | ||||
|   <PropertyGroup> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="BCrypt.Net-Next.StrongName" /> | ||||
|     <PackageReference Include="MemoryPack" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
| </Project> | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public enum AddRoleError : byte { | ||||
| 	NameIsEmpty, | ||||
							
								
								
									
										28
									
								
								Common/Phantom.Common.Data.Web/Users/AddUserError.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Common/Phantom.Common.Data.Web/Users/AddUserError.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| using System.Collections.Immutable; | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users.AddUserErrors; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users { | ||||
| 	[MemoryPackable] | ||||
| 	[MemoryPackUnion(0, typeof(NameIsInvalid))] | ||||
| 	[MemoryPackUnion(1, typeof(PasswordIsInvalid))] | ||||
| 	[MemoryPackUnion(2, typeof(NameAlreadyExists))] | ||||
| 	[MemoryPackUnion(3, typeof(UnknownError))] | ||||
| 	public abstract partial record AddUserError { | ||||
| 		internal AddUserError() {} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users.AddUserErrors { | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record NameAlreadyExists : AddUserError; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record UnknownError : AddUserError; | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users { | ||||
| 	[MemoryPackable] | ||||
| 	[MemoryPackUnion(0, typeof(Success))] | ||||
| 	[MemoryPackUnion(1, typeof(CreationFailed))] | ||||
| 	[MemoryPackUnion(2, typeof(UpdatingFailed))] | ||||
| 	[MemoryPackUnion(3, typeof(AddingToRoleFailed))] | ||||
| 	[MemoryPackUnion(4, typeof(UnknownError))] | ||||
| 	public abstract partial record CreateOrUpdateAdministratorUserResult { | ||||
| 		internal CreateOrUpdateAdministratorUserResult() {} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults { | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateOrUpdateAdministratorUserResult; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record UpdatingFailed([property: MemoryPackOrder(0)] SetUserPasswordError Error) : CreateOrUpdateAdministratorUserResult; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record AddingToRoleFailed : CreateOrUpdateAdministratorUserResult; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record UnknownError : CreateOrUpdateAdministratorUserResult; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public enum DeleteUserResult : byte { | ||||
| 	Deleted, | ||||
| @@ -1,18 +1,14 @@ | ||||
| using System.Collections.Immutable; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users.Permissions; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public sealed class IdentityPermissions { | ||||
| 	internal static IdentityPermissions None { get; } = new (); | ||||
| 	public static IdentityPermissions None { get; } = new (ImmutableHashSet<string>.Empty); | ||||
| 	 | ||||
| 	private readonly ImmutableHashSet<string> permissionIds; | ||||
| 
 | ||||
| 	internal IdentityPermissions(IQueryable<string> permissionIdsQuery) { | ||||
| 		this.permissionIds = permissionIdsQuery.ToImmutableHashSet(); | ||||
| 	} | ||||
| 
 | ||||
| 	private IdentityPermissions() { | ||||
| 		this.permissionIds = ImmutableHashSet<string>.Empty; | ||||
| 	public IdentityPermissions(ImmutableHashSet<string> permissionIdsQuery) { | ||||
| 		this.permissionIds = permissionIdsQuery; | ||||
| 	} | ||||
| 
 | ||||
| 	public bool Check(Permission? permission) { | ||||
| @@ -0,0 +1,27 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users.PasswordRequirementViolations; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users { | ||||
| 	[MemoryPackable] | ||||
| 	[MemoryPackUnion(0, typeof(TooShort))] | ||||
| 	[MemoryPackUnion(1, typeof(MustContainLowercaseLetter))] | ||||
| 	[MemoryPackUnion(2, typeof(MustContainUppercaseLetter))] | ||||
| 	[MemoryPackUnion(3, typeof(MustContainDigit))] | ||||
| 	public abstract partial record PasswordRequirementViolation { | ||||
| 		internal PasswordRequirementViolation() {} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users.PasswordRequirementViolations { | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record MustContainLowercaseLetter : PasswordRequirementViolation; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record MustContainUppercaseLetter : PasswordRequirementViolation; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record MustContainDigit : PasswordRequirementViolation; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Services.Users.Permissions; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public sealed record Permission(string Id, Permission? Parent) { | ||||
| 	private static readonly List<Permission> AllPermissions = new (); | ||||
							
								
								
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| using MemoryPack; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| [MemoryPackable(GenerateType.VersionTolerant)] | ||||
| public sealed partial record RoleInfo( | ||||
| 	[property: MemoryPackOrder(0)] Guid Guid, | ||||
| 	[property: MemoryPackOrder(1)] string Name | ||||
| ); | ||||
							
								
								
									
										24
									
								
								Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| using System.Collections.Immutable; | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users.SetUserPasswordErrors; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users { | ||||
| 	[MemoryPackable] | ||||
| 	[MemoryPackUnion(0, typeof(UserNotFound))] | ||||
| 	[MemoryPackUnion(1, typeof(PasswordIsInvalid))] | ||||
| 	[MemoryPackUnion(2, typeof(UnknownError))] | ||||
| 	public abstract partial record SetUserPasswordError { | ||||
| 		internal SetUserPasswordError() {} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users.SetUserPasswordErrors { | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record UserNotFound : SetUserPasswordError; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record UnknownError : SetUserPasswordError; | ||||
| } | ||||
							
								
								
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/UserInfo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/UserInfo.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| using MemoryPack; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| [MemoryPackable(GenerateType.VersionTolerant)] | ||||
| public sealed partial record UserInfo( | ||||
| 	[property: MemoryPackOrder(0)] Guid Guid, | ||||
| 	[property: MemoryPackOrder(1)] string Name | ||||
| ); | ||||
							
								
								
									
										12
									
								
								Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| public static class UserPasswords { | ||||
| 	public static string Hash(string password) { | ||||
| 		return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12); | ||||
| 	} | ||||
|  | ||||
| 	public static bool Verify(string password, string hash) { | ||||
| 		// TODO rehash | ||||
| 		return BCrypt.Net.BCrypt.Verify(password, hash); | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users.UsernameRequirementViolations; | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users { | ||||
| 	[MemoryPackable] | ||||
| 	[MemoryPackUnion(0, typeof(IsEmpty))] | ||||
| 	[MemoryPackUnion(1, typeof(TooLong))] | ||||
| 	public abstract partial record UsernameRequirementViolation { | ||||
| 		internal UsernameRequirementViolation() {} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| namespace Phantom.Common.Data.Web.Users.UsernameRequirementViolations { | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record IsEmpty : UsernameRequirementViolation; | ||||
|  | ||||
| 	[MemoryPackable(GenerateType.VersionTolerant)] | ||||
| 	public sealed partial record TooLong([property: MemoryPackOrder(0)] int MaxLength) : UsernameRequirementViolation; | ||||
| } | ||||
| @@ -2,7 +2,7 @@ | ||||
| using System.Security.Cryptography; | ||||
| using MemoryPack; | ||||
| 
 | ||||
| namespace Phantom.Common.Data.Agent; | ||||
| namespace Phantom.Common.Data; | ||||
| 
 | ||||
| [MemoryPackable(GenerateType.VersionTolerant)] | ||||
| [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Common.Data.Agent; | ||||
| namespace Phantom.Common.Data; | ||||
| 
 | ||||
| public readonly record struct ConnectionCommonKey(byte[] CertificatePublicKey, AuthToken AuthToken) { | ||||
| 	private const byte TokenLength = AuthToken.Length; | ||||
| @@ -1,4 +1,5 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Data.Agent; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,12 @@ | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Messages.Web.ToController; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web; | ||||
|  | ||||
| public interface IMessageToControllerListener { | ||||
| 	Task<NoReply> HandleRegisterWeb(RegisterWebMessage message); | ||||
| 	Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message); | ||||
| 	Task<NoReply> HandleReply(ReplyMessage message); | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> | ||||
|     <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" /> | ||||
|     <ProjectReference Include="..\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> | ||||
|     <ProjectReference Include="..\..\Utils\Phantom.Utils.Rpc\Phantom.Utils.Rpc.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,14 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web.ToController; | ||||
|  | ||||
| [MemoryPackable(GenerateType.VersionTolerant)] | ||||
| public sealed partial record CreateOrUpdateAdministratorUser( | ||||
| 	[property: MemoryPackOrder(0)] string Username, | ||||
| 	[property: MemoryPackOrder(1)] string Password | ||||
| ) : IMessageToController<CreateOrUpdateAdministratorUserResult> { | ||||
| 	public Task<CreateOrUpdateAdministratorUserResult> Accept(IMessageToControllerListener listener) { | ||||
| 		return listener.CreateOrUpdateAdministratorUser(this); | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| using MemoryPack; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web.ToController; | ||||
|  | ||||
| [MemoryPackable(GenerateType.VersionTolerant)] | ||||
| public sealed partial record RegisterWebMessage( | ||||
| 	[property: MemoryPackOrder(0)] AuthToken AuthToken | ||||
| ) : IMessageToController { | ||||
| 	public Task<NoReply> Accept(IMessageToControllerListener listener) { | ||||
| 		return listener.HandleRegisterWeb(this); | ||||
| 	} | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Messages.Web.ToController; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web; | ||||
| @@ -11,6 +13,8 @@ public static class WebMessageRegistries { | ||||
| 	public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions(); | ||||
|  | ||||
| 	static WebMessageRegistries() { | ||||
| 		ToController.Add<RegisterWebMessage>(0); | ||||
| 		ToController.Add<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(1); | ||||
| 		ToController.Add<ReplyMessage>(127); | ||||
| 		 | ||||
| 		ToWeb.Add<ReplyMessage>(127); | ||||
| @@ -21,7 +25,7 @@ public static class WebMessageRegistries { | ||||
| 		public MessageRegistry<IMessageToControllerListener> ToServer => ToController; | ||||
|  | ||||
| 		public bool IsRegistrationMessage(Type messageType) { | ||||
| 			return false; | ||||
| 			return messageType == typeof(RegisterWebMessage); | ||||
| 		} | ||||
|  | ||||
| 		public ReplyMessage CreateReplyMessage(uint sequenceId, byte[] serializedReply) { | ||||
|   | ||||
| @@ -4,17 +4,21 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Postgres; | ||||
|  | ||||
| public sealed class ApplicationDbContextFactory : IDatabaseProvider { | ||||
| public sealed class ApplicationDbContextFactory : IDbContextProvider { | ||||
| 	private readonly PooledDbContextFactory<ApplicationDbContext> factory; | ||||
| 	 | ||||
| 	public ApplicationDbContextFactory(string connectionString) { | ||||
| 		this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32); | ||||
| 	} | ||||
|  | ||||
| 	public ApplicationDbContext Provide() { | ||||
| 	public ApplicationDbContext Eager() { | ||||
| 		return factory.CreateDbContext(); | ||||
| 	} | ||||
|  | ||||
| 	public ILazyDbContext Lazy() { | ||||
| 		return new LazyDbContext(this); | ||||
| 	} | ||||
|  | ||||
| 	private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) { | ||||
| 		var builder = new DbContextOptionsBuilder<ApplicationDbContext>(); | ||||
| 		builder.UseNpgsql(connectionString, ConfigureOptions); | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
| namespace Phantom.Controller.Database.Postgres; | ||||
|  | ||||
| sealed class LazyDbContext : ILazyDbContext { | ||||
| 	public ApplicationDbContext Ctx => cachedContext ??= contextFactory.Eager(); | ||||
|  | ||||
| 	private readonly ApplicationDbContextFactory contextFactory; | ||||
| 	private ApplicationDbContext? cachedContext; | ||||
|  | ||||
| 	internal LazyDbContext(ApplicationDbContextFactory contextFactory) { | ||||
| 		this.contextFactory = contextFactory; | ||||
| 	} | ||||
|  | ||||
| 	public ValueTask DisposeAsync() { | ||||
| 		return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask; | ||||
| 	} | ||||
| } | ||||
| @@ -3,9 +3,10 @@ using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Data.Minecraft; | ||||
| using Phantom.Common.Data.Web.AuditLog; | ||||
| using Phantom.Common.Data.Web.EventLog; | ||||
| using Phantom.Controller.Database.Converters; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Controller.Database.Factories; | ||||
|  | ||||
| namespace Phantom.Controller.Database; | ||||
|   | ||||
| @@ -8,8 +8,8 @@ namespace Phantom.Controller.Database; | ||||
| public static class DatabaseMigrator { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator)); | ||||
| 	 | ||||
| 	public static async Task Run(IDatabaseProvider databaseProvider, CancellationToken cancellationToken) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 	public static async Task Run(IDbContextProvider dbProvider, CancellationToken cancellationToken) { | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
|  | ||||
| 		Logger.Information("Connecting to database..."); | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Text.Json; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Common.Data.Web.AuditLog; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Entities; | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Text.Json; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Common.Data.Web.EventLog; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Entities; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Entities; | ||||
|  | ||||
| @@ -11,9 +12,13 @@ public sealed class UserEntity { | ||||
| 	public string Name { get; set; } | ||||
| 	public string PasswordHash { get; set; } | ||||
|  | ||||
| 	public UserEntity(Guid userGuid, string name) { | ||||
| 	public UserEntity(Guid userGuid, string name, string passwordHash) { | ||||
| 		UserGuid = userGuid; | ||||
| 		Name = name; | ||||
| 		PasswordHash = null!; | ||||
| 		PasswordHash = passwordHash; | ||||
| 	} | ||||
| 	 | ||||
| 	public UserInfo ToUserInfo() { | ||||
| 		return new UserInfo(UserGuid, Name); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| namespace Phantom.Controller.Database.Enums; | ||||
|  | ||||
| public enum EventLogSubjectType { | ||||
| 	Instance | ||||
| } | ||||
| @@ -1,5 +0,0 @@ | ||||
| namespace Phantom.Controller.Database; | ||||
|  | ||||
| public interface IDatabaseProvider { | ||||
| 	ApplicationDbContext Provide(); | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| namespace Phantom.Controller.Database; | ||||
|  | ||||
| public interface IDbContextProvider { | ||||
| 	ApplicationDbContext Eager(); | ||||
| 	ILazyDbContext Lazy(); | ||||
| } | ||||
							
								
								
									
										5
									
								
								Controller/Phantom.Controller.Database/ILazyDbContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Controller/Phantom.Controller.Database/ILazyDbContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| namespace Phantom.Controller.Database; | ||||
|  | ||||
| public interface ILazyDbContext : IAsyncDisposable { | ||||
| 	ApplicationDbContext Ctx { get; } | ||||
| } | ||||
| @@ -15,6 +15,7 @@ | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,66 @@ | ||||
| using Phantom.Common.Data.Web.AuditLog; | ||||
| using Phantom.Controller.Database.Entities; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Repositories; | ||||
|  | ||||
| sealed partial class AuditLogRepository { | ||||
| 	public void AddUserLoggedInEvent(UserEntity user) { | ||||
| 		AddItem(AuditLogEventType.UserLoggedIn, user.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddUserLoggedOutEvent(Guid userGuid) { | ||||
| 		AddItem(AuditLogEventType.UserLoggedOut, userGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public void AddUserCreatedEvent(UserEntity user) { | ||||
| 		AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public void AddUserPasswordChangedEvent(UserEntity user) { | ||||
| 		AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) { | ||||
| 		var extra = new Dictionary<string, object?>(); | ||||
| 		 | ||||
| 		if (addedToRoles.Count > 0) { | ||||
| 			extra["addedToRoles"] = addedToRoles; | ||||
| 		} | ||||
| 		 | ||||
| 		if (removedFromRoles.Count > 0) { | ||||
| 			extra["removedFromRoles"] = removedFromRoles; | ||||
| 		} | ||||
| 		 | ||||
| 		AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra); | ||||
| 	} | ||||
| 	 | ||||
| 	public void AddUserDeletedEvent(UserEntity user) { | ||||
| 		AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "username", user.Name } | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public void AddInstanceCreatedEvent(Guid instanceGuid) { | ||||
| 		AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddInstanceEditedEvent(Guid instanceGuid) { | ||||
| 		AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public void AddInstanceLaunchedEvent(Guid instanceGuid) { | ||||
| 		AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) { | ||||
| 		AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "command", command } | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public void AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) { | ||||
| 		AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "stop_in_seconds", stopInSeconds.ToString() } | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.AuditLog; | ||||
| using Phantom.Controller.Database.Entities; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Repositories; | ||||
|  | ||||
| public sealed partial class AuditLogRepository { | ||||
| 	private readonly ILazyDbContext db; | ||||
| 	private readonly Guid? currentUserGuid; | ||||
|  | ||||
| 	public AuditLogRepository(ILazyDbContext db, Guid? currentUserGuid) { | ||||
| 		this.db = db; | ||||
| 		this.currentUserGuid = currentUserGuid; | ||||
| 	} | ||||
|  | ||||
| 	private void AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { | ||||
| 		db.Ctx.AuditLog.Add(new AuditLogEntity(currentUserGuid, eventType, subjectId, extra)); | ||||
| 	} | ||||
|  | ||||
| 	public Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) { | ||||
| 		return db.Ctx | ||||
| 		         .AuditLog | ||||
| 		         .Include(static entity => entity.User) | ||||
| 		         .AsQueryable() | ||||
| 		         .OrderByDescending(static entity => entity.UtcTime) | ||||
| 		         .Take(count) | ||||
| 		         .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) | ||||
| 		         .ToArrayAsync(cancellationToken); | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Repositories; | ||||
|  | ||||
| public sealed class RoleRepository { | ||||
| 	private const int MaxRoleNameLength = 40; | ||||
|  | ||||
| 	private readonly ILazyDbContext db; | ||||
|  | ||||
| 	public RoleRepository(ILazyDbContext db) { | ||||
| 		this.db = db; | ||||
| 	} | ||||
|  | ||||
| 	public Task<List<RoleEntity>> GetAll() { | ||||
| 		return db.Ctx.Roles.ToListAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public Task<ImmutableHashSet<string>> GetAllNames() { | ||||
| 		return db.Ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public ValueTask<RoleEntity?> GetByGuid(Guid guid) { | ||||
| 		return db.Ctx.Roles.FindAsync(guid); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<RoleEntity, AddRoleError>> Create(string name) { | ||||
| 		if (string.IsNullOrWhiteSpace(name)) { | ||||
| 			return AddRoleError.NameIsEmpty; | ||||
| 		} | ||||
| 		else if (name.Length > MaxRoleNameLength) { | ||||
| 			return AddRoleError.NameIsTooLong; | ||||
| 		} | ||||
|  | ||||
| 		if (await db.Ctx.Roles.AnyAsync(role => role.Name == name)) { | ||||
| 			return AddRoleError.NameAlreadyExists; | ||||
| 		} | ||||
|  | ||||
| 		var role = new RoleEntity(Guid.NewGuid(), name); | ||||
| 		db.Ctx.Roles.Add(role); | ||||
| 		return role; | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,115 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Data.Web.Users.AddUserErrors; | ||||
| using Phantom.Common.Data.Web.Users.PasswordRequirementViolations; | ||||
| using Phantom.Common.Data.Web.Users.UsernameRequirementViolations; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Repositories; | ||||
|  | ||||
| public sealed class UserRepository { | ||||
| 	private const int MaxUserNameLength = 40; | ||||
| 	private const int MinimumPasswordLength = 16; | ||||
|  | ||||
| 	private static UsernameRequirementViolation? CheckUsernameRequirements(string username) { | ||||
| 		if (string.IsNullOrWhiteSpace(username)) { | ||||
| 			return new IsEmpty(); | ||||
| 		} | ||||
| 		else if (username.Length > MaxUserNameLength) { | ||||
| 			return new TooLong(MaxUserNameLength); | ||||
| 		} | ||||
| 		else { | ||||
| 			return null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) { | ||||
| 		var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>(); | ||||
| 		 | ||||
| 		if (password.Length < MinimumPasswordLength) { | ||||
| 			violations.Add(new TooShort(MinimumPasswordLength)); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsLower)) { | ||||
| 			violations.Add(new MustContainLowercaseLetter()); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsUpper)) { | ||||
| 			violations.Add(new MustContainUppercaseLetter()); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsDigit)) { | ||||
| 			violations.Add(new MustContainDigit()); | ||||
| 		} | ||||
| 		 | ||||
| 		return violations.ToImmutable(); | ||||
| 	} | ||||
| 	 | ||||
| 	private readonly ILazyDbContext db; | ||||
|  | ||||
| 	private AuditLogRepository? auditLog; | ||||
| 	private AuditLogRepository AuditLogRepository => this.auditLog ??= new AuditLogRepository(db, null); | ||||
|  | ||||
| 	public UserRepository(ILazyDbContext db) { | ||||
| 		this.db = db; | ||||
| 	} | ||||
|  | ||||
| 	public Task<ImmutableArray<UserEntity>> GetAll() { | ||||
| 		return db.Ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) { | ||||
| 		return db.Ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<UserEntity?> GetByGuid(Guid guid) { | ||||
| 		return await db.Ctx.Users.FindAsync(guid); | ||||
| 	} | ||||
|  | ||||
| 	public Task<UserEntity?> GetByName(string username) { | ||||
| 		return db.Ctx.Users.FirstOrDefaultAsync(user => user.Name == username); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { | ||||
| 		var usernameRequirementViolation = CheckUsernameRequirements(username); | ||||
| 		if (usernameRequirementViolation != null) { | ||||
| 			return new NameIsInvalid(usernameRequirementViolation); | ||||
| 		} | ||||
|  | ||||
| 		var passwordRequirementViolations = CheckPasswordRequirements(password); | ||||
| 		if (!passwordRequirementViolations.IsEmpty) { | ||||
| 			return new PasswordIsInvalid(passwordRequirementViolations); | ||||
| 		} | ||||
|  | ||||
| 		if (await db.Ctx.Users.AnyAsync(user => user.Name == username)) { | ||||
| 			return new NameAlreadyExists(); | ||||
| 		} | ||||
|  | ||||
| 		var user = new UserEntity(Guid.NewGuid(), username, UserPasswords.Hash(password)); | ||||
| 		 | ||||
| 		db.Ctx.Users.Add(user); | ||||
| 		AuditLogRepository.AddUserCreatedEvent(user); | ||||
|  | ||||
| 		return user; | ||||
| 	} | ||||
|  | ||||
| 	public Result<SetUserPasswordError> SetUserPassword(UserEntity user, string password) { | ||||
| 		var requirementViolations = CheckPasswordRequirements(password); | ||||
| 		if (!requirementViolations.IsEmpty) { | ||||
| 			return new Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid(requirementViolations); | ||||
| 		} | ||||
|  | ||||
| 		user.PasswordHash = UserPasswords.Hash(password); | ||||
| 		AuditLogRepository.AddUserPasswordChangedEvent(user); | ||||
|  | ||||
| 		return Result.Ok<SetUserPasswordError>(); | ||||
| 	} | ||||
|  | ||||
| 	public void DeleteUser(UserEntity user) { | ||||
| 		db.Ctx.Users.Remove(user); | ||||
| 		AuditLogRepository.AddUserDeletedEvent(user); | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,56 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Repositories; | ||||
|  | ||||
| public sealed class UserRoleRepository { | ||||
| 	private readonly ILazyDbContext db; | ||||
|  | ||||
| 	public UserRoleRepository(ILazyDbContext db) { | ||||
| 		this.db = db; | ||||
| 	} | ||||
|  | ||||
| 	public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() { | ||||
| 		return db.Ctx.UserRoles | ||||
| 		         .Include(static ur => ur.Role) | ||||
| 		         .GroupBy(static ur => ur.UserGuid, static ur => ur.Role) | ||||
| 		         .ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray()); | ||||
| 	} | ||||
|  | ||||
| 	public Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) { | ||||
| 		return db.Ctx.UserRoles | ||||
| 		         .Include(static ur => ur.Role) | ||||
| 		         .Where(ur => ur.UserGuid == user.UserGuid) | ||||
| 		         .Select(static ur => ur.Role) | ||||
| 		         .AsAsyncEnumerable() | ||||
| 		         .ToImmutableArrayAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) { | ||||
| 		return db.Ctx.UserRoles | ||||
| 		         .Where(ur => ur.UserGuid == user.UserGuid) | ||||
| 		         .Select(static ur => ur.RoleGuid) | ||||
| 		         .AsAsyncEnumerable() | ||||
| 		         .ToImmutableSetAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Add(UserEntity user, RoleEntity role) { | ||||
| 		var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 		if (userRole == null) { | ||||
| 			db.Ctx.UserRoles.Add(new UserRoleEntity(user.UserGuid, role.RoleGuid)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task<UserRoleEntity?> Remove(UserEntity user, RoleEntity role) { | ||||
| 		var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 		if (userRole == null) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		else { | ||||
| 			db.Ctx.UserRoles.Remove(userRole); | ||||
| 			return userRole; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -11,7 +11,7 @@ using Phantom.Controller.Services.Instances; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Events; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Agents; | ||||
|  | ||||
| @@ -27,17 +27,17 @@ public sealed class AgentManager { | ||||
|  | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
| 	private readonly AuthToken authToken; | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
|  | ||||
| 	public AgentManager(AuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { | ||||
| 	public AgentManager(AuthToken authToken, IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) { | ||||
| 		this.authToken = authToken; | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 		this.dbProvider = dbProvider; | ||||
| 		this.cancellationToken = cancellationToken; | ||||
| 		taskManager.Run("Refresh agent status loop", RefreshAgentStatus); | ||||
| 	} | ||||
|  | ||||
| 	internal async Task Initialize() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
|  | ||||
| 		await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { | ||||
| 			var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory); | ||||
| @@ -68,7 +68,7 @@ public sealed class AgentManager { | ||||
| 			oldAgent.Connection?.Close(); | ||||
| 		} | ||||
|  | ||||
| 		await using (var ctx = databaseProvider.Provide()) { | ||||
| 		await using (var ctx = dbProvider.Eager()) { | ||||
| 			var entity = ctx.AgentUpsert.Fetch(agent.Guid); | ||||
|  | ||||
| 			entity.Name = agent.Name; | ||||
|   | ||||
| @@ -1,70 +0,0 @@ | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Database.Enums; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Audit; | ||||
|  | ||||
| public sealed partial class AuditLog { | ||||
| 	public Task AddAdministratorUserCreatedEvent(UserEntity administratorUser) { | ||||
| 		return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddAdministratorUserModifiedEvent(UserEntity administratorUser) { | ||||
| 		return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddUserLoggedInEvent(UserEntity user) { | ||||
| 		AddItem(user.UserGuid, AuditLogEventType.UserLoggedIn, user.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public void AddUserLoggedOutEvent(Guid userGuid) { | ||||
| 		AddItem(userGuid, AuditLogEventType.UserLoggedOut, userGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public Task AddUserCreatedEvent(UserEntity user) { | ||||
| 		return AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) { | ||||
| 		var extra = new Dictionary<string, object?>(); | ||||
| 		 | ||||
| 		if (addedToRoles.Count > 0) { | ||||
| 			extra["addedToRoles"] = addedToRoles; | ||||
| 		} | ||||
| 		 | ||||
| 		if (removedFromRoles.Count > 0) { | ||||
| 			extra["removedFromRoles"] = removedFromRoles; | ||||
| 		} | ||||
| 		 | ||||
| 		return AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra); | ||||
| 	} | ||||
| 	 | ||||
| 	public Task AddUserDeletedEvent(UserEntity user) { | ||||
| 		return AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "username", user.Name } | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddInstanceCreatedEvent(Guid instanceGuid) { | ||||
| 		return AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddInstanceEditedEvent(Guid instanceGuid) { | ||||
| 		return AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public Task AddInstanceLaunchedEvent(Guid instanceGuid) { | ||||
| 		return AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) { | ||||
| 		return AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "command", command } | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) { | ||||
| 		return AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> { | ||||
| 			{ "stop_in_seconds", stopInSeconds.ToString() } | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -1,49 +0,0 @@ | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Audit; | ||||
|  | ||||
| public sealed partial class AuditLog { | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly TaskManager taskManager; | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
|  | ||||
| 	public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 		this.taskManager = taskManager; | ||||
| 		this.cancellationToken = cancellationToken; | ||||
| 	} | ||||
| 	 | ||||
| 	private Task<Guid?> GetCurrentAuthenticatedUserId() { | ||||
| 		return Task.FromResult<Guid?>(null); // TODO | ||||
| 	} | ||||
|  | ||||
| 	private async Task AddEntityToDatabase(AuditLogEntity logEntity) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		ctx.AuditLog.Add(logEntity); | ||||
| 		await ctx.SaveChangesAsync(cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { | ||||
| 		var logEntity = new AuditLogEntity(userGuid, eventType, subjectId, extra); | ||||
| 		taskManager.Run("Store audit log item to database", () => AddEntityToDatabase(logEntity)); | ||||
| 	} | ||||
|  | ||||
| 	private async Task AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { | ||||
| 		AddItem(await GetCurrentAuthenticatedUserId(), eventType, subjectId, extra); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.AuditLog | ||||
| 		                .Include(static entity => entity.User) | ||||
| 		                .AsQueryable() | ||||
| 		                .OrderByDescending(static entity => entity.UtcTime) | ||||
| 		                .Take(count) | ||||
| 		                .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) | ||||
| 		                .ToArrayAsync(cancellationToken); | ||||
| 	} | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| using Phantom.Common.Data.Agent; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Common.Messages.Agent; | ||||
| using Phantom.Common.Messages.Web; | ||||
| @@ -10,8 +10,6 @@ using Phantom.Controller.Services.Events; | ||||
| using Phantom.Controller.Services.Instances; | ||||
| using Phantom.Controller.Services.Rpc; | ||||
| using Phantom.Controller.Services.Users; | ||||
| using Phantom.Controller.Services.Users.Permissions; | ||||
| using Phantom.Controller.Services.Users.Roles; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Services; | ||||
| @@ -19,7 +17,7 @@ namespace Phantom.Controller.Services; | ||||
| public sealed class ControllerServices { | ||||
| 	private TaskManager TaskManager { get; } | ||||
| 	private MinecraftVersions MinecraftVersions { get; } | ||||
| 	 | ||||
|  | ||||
| 	private AgentManager AgentManager { get; } | ||||
| 	private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; } | ||||
| 	private EventLog EventLog { get; } | ||||
| @@ -28,28 +26,26 @@ public sealed class ControllerServices { | ||||
| 	 | ||||
| 	private UserManager UserManager { get; } | ||||
| 	private RoleManager RoleManager { get; } | ||||
| 	private UserRoleManager UserRoleManager { get; } | ||||
| 	private PermissionManager PermissionManager { get; } | ||||
|  | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
| 	 | ||||
| 	public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) { | ||||
| 	public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) { | ||||
| 		this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>()); | ||||
| 		this.MinecraftVersions = new MinecraftVersions(); | ||||
| 		 | ||||
| 		this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.AgentManager = new AgentManager(agentAuthToken, dbProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager(); | ||||
| 		this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken); | ||||
| 		this.EventLog = new EventLog(dbProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, dbProvider, shutdownCancellationToken); | ||||
| 		this.InstanceLogManager = new InstanceLogManager(); | ||||
| 		 | ||||
| 		this.UserManager = new UserManager(databaseProvider); | ||||
| 		this.RoleManager = new RoleManager(databaseProvider); | ||||
| 		this.UserRoleManager = new UserRoleManager(databaseProvider); | ||||
| 		this.PermissionManager = new PermissionManager(databaseProvider); | ||||
| 		this.UserManager = new UserManager(dbProvider); | ||||
| 		this.RoleManager = new RoleManager(dbProvider); | ||||
| 		this.PermissionManager = new PermissionManager(dbProvider); | ||||
| 		 | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 		this.dbProvider = dbProvider; | ||||
| 		this.cancellationToken = shutdownCancellationToken; | ||||
| 	} | ||||
|  | ||||
| @@ -58,11 +54,11 @@ public sealed class ControllerServices { | ||||
| 	} | ||||
|  | ||||
| 	public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) { | ||||
| 		return new WebMessageListener(connection); | ||||
| 		return new WebMessageListener(connection, UserManager, RoleManager); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Initialize() { | ||||
| 		await DatabaseMigrator.Run(databaseProvider, cancellationToken); | ||||
| 		await DatabaseMigrator.Run(dbProvider, cancellationToken); | ||||
| 		await PermissionManager.Initialize(); | ||||
| 		await RoleManager.Initialize(); | ||||
| 		await AgentManager.Initialize(); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| using Phantom.Common.Data.Backups; | ||||
| using Phantom.Common.Data.Instance; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Common.Data.Web.EventLog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Events; | ||||
|  | ||||
|   | ||||
| @@ -1,26 +1,26 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.EventLog; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Events; | ||||
|  | ||||
| public sealed partial class EventLog { | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
| 	private readonly TaskManager taskManager; | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
| 	 | ||||
| 	public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 	public EventLog(IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) { | ||||
| 		this.dbProvider = dbProvider; | ||||
| 		this.taskManager = taskManager; | ||||
| 		this.cancellationToken = cancellationToken; | ||||
| 	} | ||||
|  | ||||
| 	private async Task AddEntityToDatabase(EventLogEntity logEntity) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
| 		ctx.EventLog.Add(logEntity); | ||||
| 		await ctx.SaveChangesAsync(cancellationToken); | ||||
| 	} | ||||
| @@ -31,7 +31,7 @@ public sealed partial class EventLog { | ||||
| 	} | ||||
|  | ||||
| 	public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
| 		return await ctx.EventLog | ||||
| 		                .AsQueryable() | ||||
| 		                .OrderByDescending(static entity => entity.UtcTime) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ using System.Collections.Immutable; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Events; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Instances; | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ using Phantom.Common.Data; | ||||
| using Phantom.Common.Data.Instance; | ||||
| using Phantom.Common.Data.Minecraft; | ||||
| using Phantom.Common.Data.Replies; | ||||
| using Phantom.Common.Data.Web.Minecraft; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Common.Messages.Agent; | ||||
| using Phantom.Common.Messages.Agent.ToAgent; | ||||
| @@ -13,11 +14,11 @@ using Phantom.Controller.Minecraft; | ||||
| using Phantom.Controller.Services.Agents; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Events; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Instances; | ||||
|  | ||||
| public sealed class InstanceManager { | ||||
| sealed class InstanceManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>(); | ||||
|  | ||||
| 	private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>()); | ||||
| @@ -26,20 +27,20 @@ public sealed class InstanceManager { | ||||
|  | ||||
| 	private readonly AgentManager agentManager; | ||||
| 	private readonly MinecraftVersions minecraftVersions; | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
| 	 | ||||
| 	private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1); | ||||
|  | ||||
| 	public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) { | ||||
| 	public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) { | ||||
| 		this.agentManager = agentManager; | ||||
| 		this.minecraftVersions = minecraftVersions; | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 		this.dbProvider = dbProvider; | ||||
| 		this.cancellationToken = cancellationToken; | ||||
| 	} | ||||
|  | ||||
| 	public async Task Initialize() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
| 		await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) { | ||||
| 			var configuration = new InstanceConfiguration( | ||||
| 				entity.AgentGuid, | ||||
| @@ -98,7 +99,7 @@ public sealed class InstanceManager { | ||||
| 			}); | ||||
| 			 | ||||
| 			if (result.Is(AddOrEditInstanceResult.Success)) { | ||||
| 				await using var ctx = databaseProvider.Provide(); | ||||
| 				await using var ctx = dbProvider.Eager(); | ||||
| 				InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid); | ||||
|  | ||||
| 				entity.AgentGuid = configuration.AgentGuid; | ||||
| @@ -188,8 +189,8 @@ public sealed class InstanceManager { | ||||
| 		try { | ||||
| 			instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically }); | ||||
|  | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken); | ||||
| 			await using var ctx = dbProvider.Eager(); | ||||
| 			var entity = await ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken); | ||||
| 			if (entity != null) { | ||||
| 				entity.LaunchAutomatically = shouldLaunchAutomatically; | ||||
| 				await ctx.SaveChangesAsync(cancellationToken); | ||||
| @@ -200,7 +201,12 @@ public sealed class InstanceManager { | ||||
| 	} | ||||
|  | ||||
| 	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { | ||||
| 		return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); | ||||
| 		var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); | ||||
| 		if (result.Is(SendCommandToInstanceResult.Success)) { | ||||
| 			// TODO audit log | ||||
| 		} | ||||
|  | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) { | ||||
|   | ||||
| @@ -11,6 +11,7 @@ | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> | ||||
|     <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> | ||||
|     <ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" /> | ||||
|     <ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" /> | ||||
|   | ||||
| @@ -1,15 +1,30 @@ | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Messages.Web.ToController; | ||||
| using Phantom.Controller.Rpc; | ||||
| using Phantom.Controller.Services.Users; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Rpc; | ||||
|  | ||||
| public sealed class WebMessageListener : IMessageToControllerListener { | ||||
| 	private readonly RpcConnectionToClient<IMessageToWebListener> connection; | ||||
| 	private readonly UserManager userManager; | ||||
| 	private readonly RoleManager roleManager; | ||||
| 	 | ||||
| 	internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) { | ||||
| 	internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection, UserManager userManager, RoleManager roleManager) { | ||||
| 		this.connection = connection; | ||||
| 		this.userManager = userManager; | ||||
| 		this.roleManager = roleManager; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<NoReply> HandleRegisterWeb(RegisterWebMessage message) { | ||||
| 		return default; | ||||
| 	} | ||||
|  | ||||
| 	public Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message) { | ||||
| 		return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); | ||||
| 	} | ||||
|  | ||||
| 	public Task<NoReply> HandleReply(ReplyMessage message) { | ||||
|   | ||||
| @@ -1,17 +0,0 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| public abstract record AddUserError { | ||||
| 	private AddUserError() {} | ||||
|  | ||||
| 	public sealed record NameIsEmpty : AddUserError; | ||||
|  | ||||
| 	public sealed record NameIsTooLong(int MaximumLength) : AddUserError; | ||||
|  | ||||
| 	public sealed record NameAlreadyExists : AddUserError; | ||||
|  | ||||
| 	public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; | ||||
|  | ||||
| 	public sealed record UnknownError : AddUserError; | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| public abstract record PasswordRequirementViolation { | ||||
| 	private PasswordRequirementViolation() {} | ||||
|  | ||||
| 	public sealed record TooShort(int MinimumLength) : PasswordRequirementViolation; | ||||
|  | ||||
| 	public sealed record LowercaseLetterRequired : PasswordRequirementViolation; | ||||
|  | ||||
| 	public sealed record UppercaseLetterRequired : PasswordRequirementViolation; | ||||
|  | ||||
| 	public sealed record DigitRequired : PasswordRequirementViolation; | ||||
| } | ||||
| @@ -1,28 +1,28 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users.Permissions; | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| 
 | ||||
| public sealed class PermissionManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>(); | ||||
| 	 | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
| 	private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new (); | ||||
| 
 | ||||
| 	public PermissionManager(IDatabaseProvider databaseProvider) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 	public PermissionManager(IDbContextProvider dbProvider) { | ||||
| 		this.dbProvider = dbProvider; | ||||
| 	} | ||||
| 
 | ||||
| 	internal async Task Initialize() { | ||||
| 		Logger.Information("Adding default permissions to database."); | ||||
| 		 | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
| 		 | ||||
| 		var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync(); | ||||
| 		var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds); | ||||
| @@ -41,28 +41,28 @@ public sealed class PermissionManager { | ||||
| 		return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); | ||||
| 	} | ||||
| 
 | ||||
| 	private IdentityPermissions FetchPermissionsForUserId(Guid userId) { | ||||
| 		using var ctx = databaseProvider.Provide(); | ||||
| 	private async Task<IdentityPermissions> FetchPermissionsForUserId(Guid userId) { | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
| 		var userPermissions = ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId); | ||||
| 		var rolePermissions = ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId); | ||||
| 		return new IdentityPermissions(userPermissions.Union(rolePermissions)); | ||||
| 		return new IdentityPermissions(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync()); | ||||
| 	} | ||||
| 
 | ||||
| 	private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) { | ||||
| 		if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) { | ||||
| 			return userPermissions; | ||||
| 		} | ||||
| 		else { | ||||
| 			return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { | ||||
| 		Guid? userId = UserManager.GetAuthenticatedUserId(user); | ||||
| 		return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache); | ||||
| 	} | ||||
| 
 | ||||
| 	public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { | ||||
| 		return GetPermissions(user, refreshCache).Check(permission); | ||||
| 	} | ||||
| 	// private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) { | ||||
| 	// 	if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) { | ||||
| 	// 		return userPermissions; | ||||
| 	// 	} | ||||
| 	// 	else { | ||||
| 	// 		return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId); | ||||
| 	// 	} | ||||
| 	// } | ||||
| 	// | ||||
| 	// public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { | ||||
| 	// 	Guid? userId = UserManager.GetAuthenticatedUserId(user); | ||||
| 	// 	return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache); | ||||
| 	// } | ||||
| 	// | ||||
| 	// public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { | ||||
| 	// 	return GetPermissions(user, refreshCache).Check(permission); | ||||
| 	// } | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Phantom.Controller.Services.Users.Permissions; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| 
 | ||||
| public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) { | ||||
| 	private static readonly List<Role> AllRoles = new (); | ||||
							
								
								
									
										52
									
								
								Controller/Phantom.Controller.Services/Users/RoleManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								Controller/Phantom.Controller.Services/Users/RoleManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| sealed class RoleManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>(); | ||||
|  | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
|  | ||||
| 	public RoleManager(IDbContextProvider dbProvider) { | ||||
| 		this.dbProvider = dbProvider; | ||||
| 	} | ||||
|  | ||||
| 	internal async Task Initialize() { | ||||
| 		Logger.Information("Adding default roles to database."); | ||||
|  | ||||
| 		await using var ctx = dbProvider.Eager(); | ||||
|  | ||||
| 		var existingRoleNames = await ctx.Roles | ||||
| 		                                 .Select(static role => role.Name) | ||||
| 		                                 .AsAsyncEnumerable() | ||||
| 		                                 .ToImmutableSetAsync(); | ||||
|  | ||||
| 		var existingPermissionIdsByRoleGuid = await ctx.RolePermissions | ||||
| 		                                               .GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId) | ||||
| 		                                               .ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet()); | ||||
|  | ||||
| 		foreach (var role in Role.All) { | ||||
| 			if (!existingRoleNames.Contains(role.Name)) { | ||||
| 				Logger.Information("Adding default role \"{Name}\".", role.Name); | ||||
| 				ctx.Roles.Add(new RoleEntity(role.Guid, role.Name)); | ||||
| 			} | ||||
|  | ||||
| 			var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty; | ||||
| 			var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds); | ||||
| 			if (!missingPermissionIds.IsEmpty) { | ||||
| 				Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds)); | ||||
| 				foreach (var permissionId in missingPermissionIds) { | ||||
| 					ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId)); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		await ctx.SaveChangesAsync(); | ||||
| 	} | ||||
| } | ||||
| @@ -1,99 +0,0 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Services.Users.Permissions; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
|  | ||||
| public sealed class RoleManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>(); | ||||
|  | ||||
| 	private const int MaxRoleNameLength = 40; | ||||
|  | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
|  | ||||
| 	public RoleManager(IDatabaseProvider databaseProvider) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 	} | ||||
|  | ||||
| 	internal async Task Initialize() { | ||||
| 		Logger.Information("Adding default roles to database."); | ||||
| 		 | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
|  | ||||
| 		var existingRoleNames = await ctx.Roles | ||||
| 		                                 .Select(static role => role.Name) | ||||
| 		                                 .AsAsyncEnumerable() | ||||
| 		                                 .ToImmutableSetAsync(); | ||||
| 		 | ||||
| 		var existingPermissionIdsByRoleGuid = await ctx.RolePermissions | ||||
| 		                                               .GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId) | ||||
| 		                                               .ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet()); | ||||
| 		 | ||||
| 		foreach (var role in Role.All) { | ||||
| 			if (!existingRoleNames.Contains(role.Name)) { | ||||
| 				Logger.Information("Adding default role \"{Name}\".", role.Name); | ||||
| 				ctx.Roles.Add(new RoleEntity(role.Guid, role.Name)); | ||||
| 			} | ||||
| 			 | ||||
| 			var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty; | ||||
| 			var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds); | ||||
| 			if (!missingPermissionIds.IsEmpty) { | ||||
| 				Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds)); | ||||
| 				foreach (var permissionId in missingPermissionIds) { | ||||
| 					ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId)); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		await ctx.SaveChangesAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<List<RoleEntity>> GetAll() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Roles.ToListAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<ImmutableHashSet<string>> GetAllNames() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask<RoleEntity?> GetByGuid(Guid guid) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Roles.FindAsync(guid); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<RoleEntity, AddRoleError>> Create(string name) { | ||||
| 		if (string.IsNullOrWhiteSpace(name)) { | ||||
| 			return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty); | ||||
| 		} | ||||
| 		else if (name.Length > MaxRoleNameLength) { | ||||
| 			return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong); | ||||
| 		} | ||||
|  | ||||
| 		RoleEntity newRole; | ||||
| 		try { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			if (await ctx.Roles.AnyAsync(role => role.Name == name)) { | ||||
| 				return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists); | ||||
| 			} | ||||
| 				 | ||||
| 			newRole = new RoleEntity(Guid.NewGuid(), name); | ||||
| 			ctx.Roles.Add(newRole); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not create role \"{Name}\".", name); | ||||
| 			return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError); | ||||
| 		} | ||||
|  | ||||
| 		Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, newRole.RoleGuid); | ||||
| 		return Result.Ok<RoleEntity, AddRoleError>(newRole); | ||||
| 	} | ||||
| } | ||||
| @@ -1,83 +0,0 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using ILogger = Serilog.ILogger; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
|  | ||||
| public sealed class UserRoleManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>(); | ||||
|  | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
|  | ||||
| 	public UserRoleManager(IDatabaseProvider databaseProvider) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.UserRoles | ||||
| 		                .Include(static ur => ur.Role) | ||||
| 		                .GroupBy(static ur => ur.UserGuid, static ur => ur.Role) | ||||
| 		                .ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray()); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.UserRoles | ||||
| 		                .Include(static ur => ur.Role) | ||||
| 		                .Where(ur => ur.UserGuid == user.UserGuid) | ||||
| 		                .Select(static ur => ur.Role) | ||||
| 		                .AsAsyncEnumerable() | ||||
| 		                .ToImmutableArrayAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.UserRoles | ||||
| 		                .Where(ur => ur.UserGuid == user.UserGuid) | ||||
| 		                .Select(static ur => ur.RoleGuid) | ||||
| 		                .AsAsyncEnumerable() | ||||
| 		                .ToImmutableSetAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<bool> Add(UserEntity user, RoleEntity role) { | ||||
| 		try { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 			if (userRole == null) { | ||||
| 				userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); | ||||
| 				ctx.UserRoles.Add(userRole); | ||||
| 				await ctx.SaveChangesAsync(); | ||||
| 			} | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<bool> Remove(UserEntity user, RoleEntity role) { | ||||
| 		try { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 			if (userRole != null) { | ||||
| 				ctx.UserRoles.Remove(userRole); | ||||
| 				await ctx.SaveChangesAsync(); | ||||
| 			} | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); | ||||
| 		return true; | ||||
| 	} | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| public abstract record SetUserPasswordError { | ||||
| 	private SetUserPasswordError() {} | ||||
|  | ||||
| 	public sealed record UserNotFound : SetUserPasswordError; | ||||
|  | ||||
| 	public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError; | ||||
| 	 | ||||
| 	public sealed record UnknownError : SetUserPasswordError; | ||||
| } | ||||
| @@ -1,136 +1,116 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Phantom.Controller.Database.Repositories; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| public sealed class UserManager { | ||||
| sealed class UserManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<UserManager>(); | ||||
|  | ||||
| 	private const int MaxUserNameLength = 40; | ||||
| 	private readonly IDbContextProvider dbProvider; | ||||
|  | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
|  | ||||
| 	public UserManager(IDatabaseProvider databaseProvider) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 	public UserManager(IDbContextProvider dbProvider) { | ||||
| 		this.dbProvider = dbProvider; | ||||
| 	} | ||||
|  | ||||
| 	public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { | ||||
| 		if (user.Identity is not { IsAuthenticated: true }) { | ||||
| 			return null; | ||||
| 		} | ||||
| 	// public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { | ||||
| 	// 	if (user.Identity is not { IsAuthenticated: true }) { | ||||
| 	// 		return null; | ||||
| 	// 	} | ||||
| 	// | ||||
| 	// 	var claim = user.FindFirst(ClaimTypes.NameIdentifier); | ||||
| 	// 	if (claim == null) { | ||||
| 	// 		return null; | ||||
| 	// 	} | ||||
| 	// | ||||
| 	// 	return Guid.TryParse(claim.Value, out var guid) ? guid : null; | ||||
| 	// } | ||||
| 	// | ||||
| 	// public async Task<UserEntity?> GetAuthenticated(string username, string password) { | ||||
| 	// 	await using var ctx = dbProvider.Lazy(); | ||||
| 	// 	var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); | ||||
| 	// 	if (user == null) { | ||||
| 	// 		return null; | ||||
| 	// 	} | ||||
| 	// | ||||
| 	// 	switch (UserValidation.VerifyPassword(user, password)) { | ||||
| 	// 		case PasswordVerificationResult.SuccessRehashNeeded: | ||||
| 	// 			try { | ||||
| 	// 				UserValidation.SetPassword(user, password); | ||||
| 	// 				await ctx.SaveChangesAsync(); | ||||
| 	// 			} catch (Exception e) { | ||||
| 	// 				Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name); | ||||
| 	// 			} | ||||
| 	// | ||||
| 	// 			goto case PasswordVerificationResult.Success; | ||||
| 	// | ||||
| 	// 		case PasswordVerificationResult.Success: | ||||
| 	// 			return user; | ||||
| 	// | ||||
| 	// 		case PasswordVerificationResult.Failed: | ||||
| 	// 			return null; | ||||
| 	// 	} | ||||
| 	// | ||||
| 	// 	throw new InvalidOperationException(); | ||||
| 	// } | ||||
|  | ||||
| 		var claim = user.FindFirst(ClaimTypes.NameIdentifier); | ||||
| 		if (claim == null) { | ||||
| 			return null; | ||||
| 		} | ||||
| 	public async Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministrator(string username, string password) { | ||||
| 		await using var db = dbProvider.Lazy(); | ||||
| 		var repository = new UserRepository(db); | ||||
|  | ||||
| 		return Guid.TryParse(claim.Value, out var guid) ? guid : null; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<ImmutableArray<UserEntity>> GetAll() { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<UserEntity?> GetByName(string username) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<UserEntity?> GetAuthenticated(string username, string password) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); | ||||
| 		return user != null && UserPasswords.Verify(user, password) ? user : null; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { | ||||
| 		if (string.IsNullOrWhiteSpace(username)) { | ||||
| 			return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty()); | ||||
| 		} | ||||
| 		else if (username.Length > MaxUserNameLength) { | ||||
| 			return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsTooLong(MaxUserNameLength)); | ||||
| 		} | ||||
|  | ||||
| 		var requirementViolations = UserPasswords.CheckRequirements(password); | ||||
| 		if (!requirementViolations.IsEmpty) { | ||||
| 			return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations)); | ||||
| 		} | ||||
|  | ||||
| 		UserEntity newUser; | ||||
| 		try { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			if (await ctx.Users.AnyAsync(user => user.Name == username)) { | ||||
| 				return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists()); | ||||
| 			} | ||||
|  | ||||
| 			newUser = new UserEntity(Guid.NewGuid(), username); | ||||
| 			UserPasswords.Set(newUser, password); | ||||
|  | ||||
| 			ctx.Users.Add(newUser); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not create user \"{Name}\".", username); | ||||
| 			return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError()); | ||||
| 		} | ||||
| 		 | ||||
| 		Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid); | ||||
| 		return Result.Ok<UserEntity, AddUserError>(newUser); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) { | ||||
| 		UserEntity foundUser; | ||||
| 		 | ||||
| 		await using (var ctx = databaseProvider.Provide()) { | ||||
| 			var user = await ctx.Users.FindAsync(guid); | ||||
| 			var user = await repository.GetByName(username); | ||||
| 			if (user == null) { | ||||
| 				return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); | ||||
| 			} | ||||
|  | ||||
| 			foundUser = user; | ||||
| 			try { | ||||
| 				var requirementViolations = UserPasswords.CheckRequirements(password); | ||||
| 				if (!requirementViolations.IsEmpty) { | ||||
| 					return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations)); | ||||
| 				var result = await repository.CreateUser(username, password); | ||||
| 				if (result) { | ||||
| 					user = result.Value; | ||||
| 				} | ||||
| 				else { | ||||
| 					return new CreationFailed(result.Error); | ||||
| 				} | ||||
| 			} | ||||
| 			else { | ||||
| 				var result = repository.SetUserPassword(user, password); | ||||
| 				if (!result) { | ||||
| 					return new UpdatingFailed(result.Error); | ||||
| 				} | ||||
|  | ||||
| 				UserPasswords.Set(user, password); | ||||
| 				await ctx.SaveChangesAsync(); | ||||
| 			} catch (Exception e) { | ||||
| 				Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 				return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError()); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid); | ||||
| 		return Result.Ok<SetUserPasswordError>(); | ||||
| 			var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid); | ||||
| 			if (role == null) { | ||||
| 				return new AddingToRoleFailed(); | ||||
| 			} | ||||
| 			 | ||||
| 			await new UserRoleRepository(db).Add(user, role); | ||||
|  | ||||
| 			Logger.Information("Created administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); | ||||
| 			return new Success(user.ToUserInfo()); | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username); | ||||
| 			return new UnknownError(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task<DeleteUserResult> DeleteByGuid(Guid guid) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		var user = await ctx.Users.FindAsync(guid); | ||||
| 		await using var db = dbProvider.Lazy(); | ||||
| 		var repository = new UserRepository(db); | ||||
| 			 | ||||
| 		var user = await repository.GetByGuid(guid); | ||||
| 		if (user == null) { | ||||
| 			return DeleteUserResult.NotFound; | ||||
| 		} | ||||
|  | ||||
| 		 | ||||
| 		try { | ||||
| 			ctx.Users.Remove(user); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 			repository.DeleteUser(user); | ||||
| 			await db.Ctx.SaveChangesAsync(); | ||||
| 			 | ||||
| 			Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 			return DeleteUserResult.Deleted; | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 			Logger.Error(e, "Could not delete user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 			return DeleteUserResult.Failed; | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -1,38 +0,0 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Phantom.Controller.Database.Entities; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| internal static class UserPasswords { | ||||
| 	private const int MinimumLength = 16; | ||||
| 	 | ||||
| 	public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) { | ||||
| 		var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>(); | ||||
| 		 | ||||
| 		if (password.Length < MinimumLength) { | ||||
| 			violations.Add(new PasswordRequirementViolation.TooShort(MinimumLength)); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsLower)) { | ||||
| 			violations.Add(new PasswordRequirementViolation.LowercaseLetterRequired()); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsUpper)) { | ||||
| 			violations.Add(new PasswordRequirementViolation.UppercaseLetterRequired()); | ||||
| 		} | ||||
|  | ||||
| 		if (!password.Any(char.IsDigit)) { | ||||
| 			violations.Add(new PasswordRequirementViolation.DigitRequired()); | ||||
| 		} | ||||
| 		 | ||||
| 		return violations.ToImmutable(); | ||||
| 	} | ||||
|  | ||||
| 	public static void Set(UserEntity user, string password) { | ||||
| 		user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password); | ||||
| 	} | ||||
| 	 | ||||
| 	public static bool Verify(UserEntity user, string password) { | ||||
| 		return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash); | ||||
| 	} | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| using NetMQ; | ||||
| using Phantom.Common.Data.Agent; | ||||
| using Phantom.Common.Data; | ||||
|  | ||||
| namespace Phantom.Controller; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| using NetMQ; | ||||
| using Phantom.Common.Data.Agent; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Cryptography; | ||||
| using Phantom.Utils.IO; | ||||
|   | ||||
| @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Comm | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests", "Common\Phantom.Common.Data.Tests\Phantom.Common.Data.Tests.csproj", "{435D7981-DFDA-46A0-8CD8-CD8C117935D7}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Web", "Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj", "{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Agent", "Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" | ||||
| @@ -58,7 +60,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Bootstrap", "We | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Components", "Web\Phantom.Web.Components\Phantom.Web.Components.csproj", "{3F4F9059-F869-42D3-B92C-90D27ADFC42D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Identity", "Web\Phantom.Web.Identity\Phantom.Web.Identity.csproj", "{A9870842-FE7A-4760-95DC-9D485DDDA31F}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Services", "Web\Phantom.Web.Services\Phantom.Web.Services.csproj", "{7B0EEE34-A586-4629-AC51-16757DE53261}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| @@ -90,6 +92,10 @@ Global | ||||
| 		{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| @@ -150,10 +156,10 @@ Global | ||||
| 		{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(NestedProjects) = preSolution | ||||
| 		{418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127} | ||||
| @@ -165,6 +171,7 @@ Global | ||||
| 		{95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} | ||||
| 		{6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} | ||||
| 		{435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5} | ||||
| 		{BC969D0B-0019-48E0-9FAF-F5CC906AAF09} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} | ||||
| 		{A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} | ||||
| 		{E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} | ||||
| 		{81625B4A-3DB6-48BD-A739-D23DA02107D1} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} | ||||
| @@ -178,6 +185,6 @@ Global | ||||
| 		{7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {92B26F48-235F-4500-BD55-800F06A0BA39} | ||||
| 		{83FA86DB-34E4-4C2C-832C-90F491CA10C7} = {92B26F48-235F-4500-BD55-800F06A0BA39} | ||||
| 		{3F4F9059-F869-42D3-B92C-90D27ADFC42D} = {92B26F48-235F-4500-BD55-800F06A0BA39} | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F} = {92B26F48-235F-4500-BD55-800F06A0BA39} | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261} = {92B26F48-235F-4500-BD55-800F06A0BA39} | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
|   | ||||
| @@ -149,8 +149,8 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit | ||||
|    - `Controller` starts the Controller. | ||||
|    - `Web` starts the Web server. | ||||
|    - `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents. | ||||
|    - `Controller + Agent` starts the Controller and Agent 1. | ||||
|    - `Controller + Agent x3` starts the Controller and Agent 1, 2, and 3. | ||||
|    - `Controller + Web + Agent` starts the Controller and Agent 1. | ||||
|    - `Controller + Web + Agent x3` starts the Controller and Agent 1, 2, and 3. | ||||
|  | ||||
| ## Bootstrap | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Utils.Rpc.Message;  | ||||
| namespace Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| public interface IReply { | ||||
| 	uint SequenceId { get; } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| using NetMQ.Sockets; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Utils.Rpc;  | ||||
| namespace Phantom.Utils.Rpc; | ||||
|  | ||||
| public sealed class RpcConnectionToServer<TListener> { | ||||
| 	private readonly ClientSocket socket; | ||||
|   | ||||
| @@ -2,17 +2,57 @@ | ||||
|  | ||||
| public abstract record Result<TValue, TError> { | ||||
| 	private Result() {} | ||||
|  | ||||
| 	public abstract TValue Value { get; init; } | ||||
| 	public abstract TError Error { get; init; } | ||||
|  | ||||
| 	public static implicit operator Result<TValue, TError>(TValue value) { | ||||
| 		return new Ok(value); | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Ok(TValue Value) : Result<TValue, TError>; | ||||
| 	public static implicit operator Result<TValue, TError>(TError error) { | ||||
| 		return new Fail(error); | ||||
| 	} | ||||
|  | ||||
| 	public static implicit operator bool(Result<TValue, TError> result) { | ||||
| 		return result is Ok; | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Fail(TError Error) : Result<TValue, TError>; | ||||
| 	public sealed record Ok(TValue Value) : Result<TValue, TError> { | ||||
| 		public override TError Error { | ||||
| 			get => throw new InvalidOperationException("Attempted to get error from Ok result."); | ||||
| 			init {} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public sealed record Fail(TError Error) : Result<TValue, TError> { | ||||
| 		public override TValue Value { | ||||
| 			get => throw new InvalidOperationException("Attempted to get value from Fail result."); | ||||
| 			init {} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| public abstract record Result<TError> { | ||||
| 	private Result() {} | ||||
| 	 | ||||
| 	public abstract TError Error { get; init; } | ||||
|  | ||||
| 	public static implicit operator Result<TError>(TError error) { | ||||
| 		return new Fail(error); | ||||
| 	} | ||||
| 	 | ||||
| 	public static implicit operator bool(Result<TError> result) { | ||||
| 		return result is Ok; | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Ok : Result<TError> { | ||||
| 		internal static Ok Instance { get; } = new (); | ||||
| 		 | ||||
| 		public override TError Error { | ||||
| 			get => throw new InvalidOperationException("Attempted to get error from Ok result."); | ||||
| 			init {} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Fail(TError Error) : Result<TError>; | ||||
|   | ||||
| @@ -1,8 +0,0 @@ | ||||
| using Phantom.Controller.Database.Entities; | ||||
|  | ||||
| namespace Phantom.Web.Identity.Interfaces; | ||||
|  | ||||
| public interface ILoginEvents { | ||||
| 	void UserLoggedIn(UserEntity user); | ||||
| 	void UserLoggedOut(Guid userGuid); | ||||
| } | ||||
| @@ -1,9 +1,7 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Phantom.Web.Identity.Authentication; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity; | ||||
| namespace Phantom.Web.Services.Authentication; | ||||
| 
 | ||||
| sealed class PhantomIdentityMiddleware { | ||||
| 	public const string LoginPath = "/login"; | ||||
| @@ -2,12 +2,10 @@ | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Services.Users; | ||||
| using Phantom.Utils.Cryptography; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
| using ILogger = Serilog.ILogger; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Authentication; | ||||
| namespace Phantom.Web.Services.Authentication; | ||||
| 
 | ||||
| public sealed class PhantomLoginManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>(); | ||||
| @@ -4,7 +4,7 @@ using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Authentication; | ||||
| namespace Phantom.Web.Services.Authentication; | ||||
| 
 | ||||
| public sealed class PhantomLoginStore { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>(); | ||||
| @@ -1,6 +1,6 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Authorization; | ||||
| namespace Phantom.Web.Services.Authorization; | ||||
| 
 | ||||
| sealed class PermissionBasedPolicyHandler : AuthorizationHandler<PermissionBasedPolicyRequirement> { | ||||
| 	private readonly PermissionManager permissionManager; | ||||
| @@ -1,6 +1,6 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Phantom.Web.Identity.Data; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Authorization; | ||||
| namespace Phantom.Web.Services.Authorization; | ||||
| 
 | ||||
| sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement; | ||||
							
								
								
									
										15
									
								
								Web/Phantom.Web.Services/Authorization/PermissionManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								Web/Phantom.Web.Services/Authorization/PermissionManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| using System.Security.Claims; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| namespace Phantom.Web.Services.Authorization; | ||||
|  | ||||
| // TODO | ||||
| public class PermissionManager { | ||||
| 	public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { | ||||
| 		 | ||||
| 	} | ||||
| 	 | ||||
| 	public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { | ||||
| 		return false; | ||||
| 	} | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| @using Microsoft.AspNetCore.Components.Authorization | ||||
| @using Phantom.Web.Identity.Data | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @inject PermissionManager PermissionManager | ||||
| 
 | ||||
| <AuthorizeView> | ||||
| @@ -1,6 +1,6 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Interfaces; | ||||
| namespace Phantom.Web.Services; | ||||
| 
 | ||||
| public interface INavigation { | ||||
| 	string BasePath { get; } | ||||
							
								
								
									
										10
									
								
								Web/Phantom.Web.Services/Instances/InstanceManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								Web/Phantom.Web.Services/Instances/InstanceManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| using Phantom.Common.Data.Replies; | ||||
|  | ||||
| namespace Phantom.Web.Services.Instances; | ||||
|  | ||||
| // TODO | ||||
| public class InstanceManager { | ||||
| 	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { | ||||
| 		 | ||||
| 	} | ||||
| } | ||||
| @@ -10,17 +10,14 @@ | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <SupportedPlatform Include="browser" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Components.Web" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> | ||||
|     <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" /> | ||||
|     <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <AdditionalFiles Include="Authorization\PermissionView.razor" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
| </Project> | ||||
| @@ -1,43 +1,33 @@ | ||||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Components.Authorization; | ||||
| using Microsoft.AspNetCore.Components.Server; | ||||
| using Phantom.Web.Identity.Authentication; | ||||
| using Phantom.Web.Identity.Authorization; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Web.Services.Authentication; | ||||
| using Phantom.Web.Services.Authorization; | ||||
| using Phantom.Web.Services.Rpc; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity; | ||||
| 
 | ||||
| public static class PhantomIdentityExtensions { | ||||
| 	public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) { | ||||
| 		services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie); | ||||
| 		services.AddAuthorization(ConfigureAuthorization); | ||||
| namespace Phantom.Web.Services; | ||||
| 
 | ||||
| public static class PhantomWebServices { | ||||
| 	public static void AddPhantomServices(this IServiceCollection services, CancellationToken cancellationToken) { | ||||
| 		services.AddSingleton<MessageListener>(); | ||||
| 		services.AddSingleton<ControllerCommunication>(); | ||||
| 		services.AddSingleton<PermissionManager>(); | ||||
| 		 | ||||
| 		services.AddSingleton(PhantomLoginStore.Create(cancellationToken)); | ||||
| 		services.AddScoped<PhantomLoginManager>(); | ||||
| 		 | ||||
| 		services.AddAuthorization(ConfigureAuthorization); | ||||
| 		services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); | ||||
| 		services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); | ||||
| 	} | ||||
| 
 | ||||
| 	public static void UsePhantomIdentity(this IApplicationBuilder application) { | ||||
| 	 | ||||
| 	public static void UsePhantomServices(this IApplicationBuilder application) { | ||||
| 		application.UseAuthentication(); | ||||
| 		application.UseAuthorization(); | ||||
| 		application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>()); | ||||
| 	} | ||||
| 
 | ||||
| 	private static void ConfigureIdentityCookie(CookieAuthenticationOptions o) { | ||||
| 		o.Cookie.Name = "Phantom.Identity"; | ||||
| 		o.Cookie.HttpOnly = true; | ||||
| 		o.Cookie.SameSite = SameSiteMode.Lax; | ||||
| 
 | ||||
| 		o.ExpireTimeSpan = TimeSpan.FromDays(30); | ||||
| 		o.SlidingExpiration = true; | ||||
| 
 | ||||
| 		o.LoginPath = PhantomIdentityMiddleware.LoginPath; | ||||
| 		o.LogoutPath = PhantomIdentityMiddleware.LogoutPath; | ||||
| 		o.AccessDeniedPath = PhantomIdentityMiddleware.LoginPath; | ||||
| 	} | ||||
| 
 | ||||
| 	private static void ConfigureAuthorization(AuthorizationOptions o) { | ||||
| 		foreach (var permission in Permission.All) { | ||||
| 			o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission))); | ||||
							
								
								
									
										20
									
								
								Web/Phantom.Web.Services/Rpc/ControllerCommunication.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								Web/Phantom.Web.Services/Rpc/ControllerCommunication.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Utils.Rpc; | ||||
|  | ||||
| namespace Phantom.Web.Services.Rpc; | ||||
|  | ||||
| public sealed class ControllerCommunication { | ||||
| 	private readonly RpcConnectionToServer<IMessageToControllerListener> connection; | ||||
| 	 | ||||
| 	public ControllerCommunication(RpcConnectionToServer<IMessageToControllerListener> connection) { | ||||
| 		this.connection = connection; | ||||
| 	} | ||||
|  | ||||
| 	public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController { | ||||
| 		return connection.Send(message); | ||||
| 	} | ||||
|  | ||||
| 	public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan timeout) where TMessage : IMessageToController<TReply> where TReply : class { | ||||
| 		return connection.Send<TMessage, TReply>(message, timeout, CancellationToken.None); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										19
									
								
								Web/Phantom.Web.Services/Rpc/MessageListener.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								Web/Phantom.Web.Services/Rpc/MessageListener.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Utils.Rpc; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Web.Services.Rpc;  | ||||
|  | ||||
| public sealed class MessageListener : IMessageToWebListener { | ||||
| 	private readonly RpcConnectionToServer<IMessageToControllerListener> connection; | ||||
| 	 | ||||
| 	public MessageListener(RpcConnectionToServer<IMessageToControllerListener> connection) { | ||||
| 		this.connection = connection; | ||||
| 	} | ||||
|  | ||||
| 	public Task<NoReply> HandleReply(ReplyMessage message) { | ||||
| 		connection.Receive(message); | ||||
| 		return Task.FromResult(NoReply.Instance); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										14
									
								
								Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Utils.Rpc; | ||||
| using Phantom.Utils.Rpc.Sockets; | ||||
|  | ||||
| namespace Phantom.Web.Services.Rpc; | ||||
|  | ||||
| public sealed class RpcClientRuntime : RpcClientRuntime<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> { | ||||
| 	public static Task Launch(RpcClientSocket<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> socket, IMessageToWebListener messageListener, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { | ||||
| 		return new RpcClientRuntime(socket, messageListener, disconnectSemaphore, receiveCancellationToken).Launch(); | ||||
| 	} | ||||
|  | ||||
| 	private RpcClientRuntime(RpcClientSocket<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> socket, IMessageToWebListener messageListener, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(socket, messageListener, disconnectSemaphore, receiveCancellationToken) {} | ||||
| } | ||||
							
								
								
									
										5
									
								
								Web/Phantom.Web.Services/Users/UserManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Web/Phantom.Web.Services/Users/UserManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| namespace Phantom.Web.Services.Users; | ||||
|  | ||||
| public sealed class UserManager { | ||||
| 	 | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| @using Phantom.Web.Identity.Interfaces | ||||
| @using Phantom.Web.Identity.Authentication | ||||
| @using Phantom.Web.Services | ||||
| @using Phantom.Web.Services.Authentication | ||||
| @inject INavigation Nav | ||||
| @inject NavigationManager NavigationManager | ||||
|  | ||||
|   | ||||
| @@ -1,21 +0,0 @@ | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Services.Audit; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
|  | ||||
| namespace Phantom.Web.Base; | ||||
|  | ||||
| sealed class LoginEvents : ILoginEvents { | ||||
| 	private readonly AuditLog auditLog; | ||||
|  | ||||
| 	public LoginEvents(AuditLog auditLog) { | ||||
| 		this.auditLog = auditLog; | ||||
| 	} | ||||
|  | ||||
| 	public void UserLoggedIn(UserEntity user) { | ||||
| 		auditLog.AddUserLoggedInEvent(user); | ||||
| 	} | ||||
|  | ||||
| 	public void UserLoggedOut(Guid userGuid) { | ||||
| 		auditLog.AddUserLoggedOutEvent(userGuid); | ||||
| 	} | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Web; | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
| using Phantom.Web.Services; | ||||
|  | ||||
| namespace Phantom.Web.Base; | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using Microsoft.AspNetCore.Components.Authorization; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Web.Identity.Authorization; | ||||
| using Phantom.Web.Identity.Data; | ||||
| using Phantom.Web.Services.Authorization; | ||||
| using ILogger = Serilog.ILogger; | ||||
|  | ||||
| namespace Phantom.Web.Base; | ||||
|   | ||||
| @@ -2,6 +2,6 @@ | ||||
|  | ||||
| namespace Phantom.Web; | ||||
|  | ||||
| public sealed record Configuration(ILogger Logger, string Host, ushort Port, string BasePath, string KeyFolderPath, CancellationToken CancellationToken) { | ||||
| sealed record Configuration(ILogger Logger, string Host, ushort Port, string BasePath, string DataProtectionKeyFolderPath, CancellationToken CancellationToken) { | ||||
| 	public string HttpUrl => "http://" + Host + ":" + Port; | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| @using Phantom.Controller.Services | ||||
| @using Phantom.Web.Services.Authorization | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @inject ServiceConfiguration Configuration | ||||
| @inject PermissionManager PermissionManager | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| @page | ||||
| @using Phantom.Web.Identity.Interfaces | ||||
| @using Phantom.Web.Services | ||||
| @model Phantom.Web.Layout.ErrorModel | ||||
| @inject INavigation Navigation | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| @using Phantom.Web.Identity.Interfaces | ||||
| @using Phantom.Web.Services | ||||
| @namespace Phantom.Web.Layout | ||||
| @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | ||||
| @inject INavigation Navigation | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user