diff --git a/.run/Controller + Agent x3.run.xml b/.run/Controller + Web + Agent x3.run.xml
similarity index 67%
rename from .run/Controller + Agent x3.run.xml
rename to .run/Controller + Web + Agent x3.run.xml
index 2fe0300..110e96a 100644
--- a/.run/Controller + Agent x3.run.xml	
+++ b/.run/Controller + Web + Agent x3.run.xml	
@@ -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>
\ No newline at end of file
diff --git a/.run/Controller + Agent.run.xml b/.run/Controller + Web + Agent.run.xml
similarity index 58%
rename from .run/Controller + Agent.run.xml
rename to .run/Controller + Web + Agent.run.xml
index 2e299db..1178914 100644
--- a/.run/Controller + Agent.run.xml	
+++ b/.run/Controller + Web + Agent.run.xml	
@@ -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>
\ No newline at end of file
diff --git a/.run/Web.run.xml b/.run/Web.run.xml
new file mode 100644
index 0000000..554e06f
--- /dev/null
+++ b/.run/Web.run.xml
@@ -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>
\ No newline at end of file
diff --git a/Agent/Phantom.Agent/AgentKey.cs b/Agent/Phantom.Agent/AgentKey.cs
index dd2ed1c..85776c3 100644
--- a/Agent/Phantom.Agent/AgentKey.cs
+++ b/Agent/Phantom.Agent/AgentKey.cs
@@ -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;
diff --git a/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs b/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs
new file mode 100644
index 0000000..6aba38d
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs
@@ -0,0 +1,22 @@
+using MemoryPack;
+using Phantom.Common.Data.Agent;
+
+namespace Phantom.Common.Data.Web.Agent;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentWithStats(
+	[property: MemoryPackOrder(0)] Guid Guid,
+	[property: MemoryPackOrder(1)] string Name,
+	[property: MemoryPackOrder(2)] ushort ProtocolVersion,
+	[property: MemoryPackOrder(3)] string BuildVersion,
+	[property: MemoryPackOrder(4)] ushort MaxInstances,
+	[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory,
+	[property: MemoryPackOrder(6)] AllowedPorts? AllowedServerPorts,
+	[property: MemoryPackOrder(7)] AllowedPorts? AllowedRconPorts,
+	[property: MemoryPackOrder(8)] AgentStats? Stats,
+	[property: MemoryPackOrder(9)] DateTimeOffset? LastPing,
+	[property: MemoryPackOrder(10)] bool IsOnline
+) {
+	[MemoryPackIgnore]
+	public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory;
+}
diff --git a/Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs
similarity index 85%
rename from Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs
rename to Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs
index 060e588..c82bc26 100644
--- a/Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs
+++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs
@@ -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];
 	}
 }
diff --git a/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs
new file mode 100644
index 0000000..f5870cf
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs
@@ -0,0 +1,14 @@
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.AuditLog;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AuditLogItem(
+	[property: MemoryPackOrder(0)] DateTime UtcTime,
+	[property: MemoryPackOrder(1)] Guid? UserGuid,
+	[property: MemoryPackOrder(2)] string? UserName,
+	[property: MemoryPackOrder(3)] AuditLogEventType EventType,
+	[property: MemoryPackOrder(4)] AuditLogSubjectType SubjectType,
+	[property: MemoryPackOrder(5)] string? SubjectId,
+	[property: MemoryPackOrder(6)] string? JsonData
+);
diff --git a/Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs
similarity index 52%
rename from Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs
rename to Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs
index 7bc84a7..34094f9 100644
--- a/Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs
+++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs
@@ -1,4 +1,4 @@
-namespace Phantom.Controller.Database.Enums;
+namespace Phantom.Common.Data.Web.AuditLog;
 
 public enum AuditLogSubjectType {
 	User,
diff --git a/Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs
similarity index 92%
rename from Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs
rename to Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs
index b9d27f3..120f9c8 100644
--- a/Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs
+++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs
@@ -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 },
diff --git a/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs
new file mode 100644
index 0000000..9aae689
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs
@@ -0,0 +1,13 @@
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.EventLog;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record EventLogItem(
+	[property: MemoryPackOrder(0)] DateTime UtcTime,
+	[property: MemoryPackOrder(1)] Guid? AgentGuid,
+	[property: MemoryPackOrder(2)] EventLogEventType EventType,
+	[property: MemoryPackOrder(3)] EventLogSubjectType SubjectType,
+	[property: MemoryPackOrder(4)] string SubjectId,
+	[property: MemoryPackOrder(5)] string? JsonData
+);
diff --git a/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs
new file mode 100644
index 0000000..06bebf9
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs
@@ -0,0 +1,5 @@
+namespace Phantom.Common.Data.Web.EventLog;
+
+public enum EventLogSubjectType {
+	Instance
+}
diff --git a/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs b/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs
new file mode 100644
index 0000000..89dd4bc
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs
@@ -0,0 +1,23 @@
+namespace Phantom.Common.Data.Web.Instance;
+
+public enum CreateOrUpdateInstanceResult : byte {
+	UnknownError,
+	Success,
+	InstanceNameMustNotBeEmpty,
+	InstanceMemoryMustNotBeZero,
+	MinecraftVersionDownloadInfoNotFound,
+	AgentNotFound
+}
+
+public static class CreateOrUpdateInstanceResultExtensions {
+	public static string ToSentence(this CreateOrUpdateInstanceResult reason) {
+		return reason switch {
+			CreateOrUpdateInstanceResult.Success                              => "Success.",
+			CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty           => "Instance name must not be empty.",
+			CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero          => "Memory must not be 0 MB.",
+			CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.",
+			CreateOrUpdateInstanceResult.AgentNotFound                        => "Agent not found.",
+			_                                                                 => "Unknown error."
+		};
+	}
+}
diff --git a/Common/Phantom.Common.Data.Web/Instance/Instance.cs b/Common/Phantom.Common.Data.Web/Instance/Instance.cs
new file mode 100644
index 0000000..fe2ac10
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Instance/Instance.cs
@@ -0,0 +1,15 @@
+using MemoryPack;
+using Phantom.Common.Data.Instance;
+
+namespace Phantom.Common.Data.Web.Instance;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record Instance(
+	[property: MemoryPackOrder(0)] InstanceConfiguration Configuration,
+	[property: MemoryPackOrder(1)] IInstanceStatus Status,
+	[property: MemoryPackOrder(2)] bool LaunchAutomatically
+) {
+	public static Instance Offline(InstanceConfiguration configuration, bool launchAutomatically = false) {
+		return new Instance(configuration, InstanceStatus.Offline, launchAutomatically);
+	}
+}
diff --git a/Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs b/Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs
similarity index 95%
rename from Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs
rename to Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs
index d43d0ea..ab6e949 100644
--- a/Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs
+++ b/Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs
@@ -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) {
diff --git a/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj b/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj
new file mode 100644
index 0000000..b9fc41f
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  
+  <PropertyGroup>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+  
+  <ItemGroup>
+    <PackageReference Include="BCrypt.Net-Next.StrongName" />
+    <PackageReference Include="MemoryPack" />
+  </ItemGroup>
+  
+  <ItemGroup>
+    <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" />
+  </ItemGroup>
+  
+</Project>
diff --git a/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs b/Common/Phantom.Common.Data.Web/Users/AddRoleError.cs
similarity index 65%
rename from Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs
rename to Common/Phantom.Common.Data.Web/Users/AddRoleError.cs
index df1d8d5..5c02d93 100644
--- a/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs
+++ b/Common/Phantom.Common.Data.Web/Users/AddRoleError.cs
@@ -1,4 +1,4 @@
-namespace Phantom.Controller.Services.Users.Roles;
+namespace Phantom.Common.Data.Web.Users;
 
 public enum AddRoleError : byte {
 	NameIsEmpty,
diff --git a/Common/Phantom.Common.Data.Web/Users/AddUserError.cs b/Common/Phantom.Common.Data.Web/Users/AddUserError.cs
new file mode 100644
index 0000000..2e30852
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/AddUserError.cs
@@ -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;
+}
diff --git a/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs b/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs
new file mode 100644
index 0000000..e46eec0
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs
@@ -0,0 +1,10 @@
+using System.Collections.Immutable;
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.Users; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record ChangeUserRolesResult(
+	[property: MemoryPackOrder(0)] ImmutableHashSet<Guid> AddedToRoleGuids,
+	[property: MemoryPackOrder(1)] ImmutableHashSet<Guid> RemovedFromRoleGuids
+);
diff --git a/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs b/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs
new file mode 100644
index 0000000..d846e1c
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs
@@ -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;
+}
diff --git a/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs b/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs
new file mode 100644
index 0000000..1925971
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs
@@ -0,0 +1,23 @@
+using MemoryPack;
+using Phantom.Common.Data.Web.Users.CreateUserResults;
+
+namespace Phantom.Common.Data.Web.Users {
+	[MemoryPackable]
+	[MemoryPackUnion(0, typeof(Success))]
+	[MemoryPackUnion(1, typeof(CreationFailed))]
+	[MemoryPackUnion(2, typeof(UnknownError))]
+	public abstract partial record CreateUserResult {
+		internal CreateUserResult() {}
+	}
+}
+
+namespace Phantom.Common.Data.Web.Users.CreateUserResults {
+	[MemoryPackable(GenerateType.VersionTolerant)]
+	public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateUserResult;
+
+	[MemoryPackable(GenerateType.VersionTolerant)]
+	public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateUserResult;
+
+	[MemoryPackable(GenerateType.VersionTolerant)]
+	public sealed partial record UnknownError : CreateUserResult;
+}
diff --git a/Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs b/Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs
similarity index 59%
rename from Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs
rename to Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs
index fcb6229..693e58d 100644
--- a/Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs
+++ b/Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs
@@ -1,4 +1,4 @@
-namespace Phantom.Controller.Services.Users;
+namespace Phantom.Common.Data.Web.Users;
 
 public enum DeleteUserResult : byte {
 	Deleted,
diff --git a/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs b/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs
new file mode 100644
index 0000000..e09cc56
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs
@@ -0,0 +1,11 @@
+using System.Collections.Immutable;
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.Users;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record LogInSuccess (
+	[property: MemoryPackOrder(0)] Guid UserGuid,
+	[property: MemoryPackOrder(1)] PermissionSet Permissions,
+	[property: MemoryPackOrder(2)] ImmutableArray<byte> Token
+);
diff --git a/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs b/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs
new file mode 100644
index 0000000..c10c662
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs
@@ -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;
+}
diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs b/Common/Phantom.Common.Data.Web/Users/Permission.cs
similarity index 96%
rename from Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs
rename to Common/Phantom.Common.Data.Web/Users/Permission.cs
index 3489f6d..6220c89 100644
--- a/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs
+++ b/Common/Phantom.Common.Data.Web/Users/Permission.cs
@@ -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 ();
diff --git a/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs b/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs
new file mode 100644
index 0000000..437f36c
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs
@@ -0,0 +1,29 @@
+using System.Collections.Immutable;
+using MemoryPack;
+
+namespace Phantom.Common.Data.Web.Users;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial class PermissionSet {
+	public static PermissionSet None { get; } = new (ImmutableHashSet<string>.Empty);
+	
+	[MemoryPackOrder(0)]
+	[MemoryPackInclude]
+	private readonly ImmutableHashSet<string> permissionIds;
+
+	public PermissionSet(ImmutableHashSet<string> permissionIds) {
+		this.permissionIds = permissionIds;
+	}
+
+	public bool Check(Permission? permission) {
+		while (permission != null) {
+			if (!permissionIds.Contains(permission.Id)) {
+				return false;
+			}
+
+			permission = permission.Parent;
+		}
+
+		return true;
+	}
+}
diff --git a/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs b/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
new file mode 100644
index 0000000..257223b
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
@@ -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
+);
diff --git a/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs b/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
new file mode 100644
index 0000000..1cc0252
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
@@ -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;
+}
diff --git a/Common/Phantom.Common.Data.Web/Users/UserInfo.cs b/Common/Phantom.Common.Data.Web/Users/UserInfo.cs
new file mode 100644
index 0000000..0c1ef02
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/UserInfo.cs
@@ -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
+);
diff --git a/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs b/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
new file mode 100644
index 0000000..962bb01
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
@@ -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);
+	}
+}
diff --git a/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs b/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs
new file mode 100644
index 0000000..37433dd
--- /dev/null
+++ b/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs
@@ -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;
+}
diff --git a/Common/Phantom.Common.Data/Agent/AgentStats.cs b/Common/Phantom.Common.Data/Agent/AgentStats.cs
new file mode 100644
index 0000000..4d3eb2e
--- /dev/null
+++ b/Common/Phantom.Common.Data/Agent/AgentStats.cs
@@ -0,0 +1,9 @@
+using MemoryPack;
+
+namespace Phantom.Common.Data.Agent;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record AgentStats(
+	[property: MemoryPackOrder(0)] int RunningInstanceCount,
+	[property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory
+);
diff --git a/Common/Phantom.Common.Data/Agent/AuthToken.cs b/Common/Phantom.Common.Data/AuthToken.cs
similarity index 96%
rename from Common/Phantom.Common.Data/Agent/AuthToken.cs
rename to Common/Phantom.Common.Data/AuthToken.cs
index 20aec72..34d6078 100644
--- a/Common/Phantom.Common.Data/Agent/AuthToken.cs
+++ b/Common/Phantom.Common.Data/AuthToken.cs
@@ -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")]
diff --git a/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs b/Common/Phantom.Common.Data/ConnectionCommonKey.cs
similarity index 94%
rename from Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs
rename to Common/Phantom.Common.Data/ConnectionCommonKey.cs
index 8eaea10..a2fbedb 100644
--- a/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs
+++ b/Common/Phantom.Common.Data/ConnectionCommonKey.cs
@@ -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;
diff --git a/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs b/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs
index 8316552..64302a1 100644
--- a/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs
+++ b/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs
@@ -1,7 +1,10 @@
-namespace Phantom.Common.Data.Minecraft;
+using MemoryPack;
 
-public sealed record MinecraftVersion(
-	string Id,
-	MinecraftVersionType Type,
-	string MetadataUrl
+namespace Phantom.Common.Data.Minecraft;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record MinecraftVersion(
+	[property: MemoryPackOrder(0)] string Id,
+	[property: MemoryPackOrder(1)] MinecraftVersionType Type,
+	[property: MemoryPackOrder(2)] string MetadataUrl
 );
diff --git a/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs
index d3fbcac..fbadb61 100644
--- a/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs
+++ b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs
@@ -1,4 +1,5 @@
 using MemoryPack;
+using Phantom.Common.Data;
 using Phantom.Common.Data.Agent;
 using Phantom.Utils.Rpc.Message;
 
diff --git a/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs
index b265869..49048e7 100644
--- a/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs
+++ b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs
@@ -1,8 +1,35 @@
-using Phantom.Common.Messages.Web.BiDirectional;
+using System.Collections.Immutable;
+using Phantom.Common.Data.Java;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Common.Data.Web.Instance;
+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<NoReply> HandleUnregisterWeb(UnregisterWebMessage message);
+	Task<LogInSuccess?> HandleLogIn(LogInMessage message);
+	Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message);
+	Task<CreateUserResult> HandleCreateUser(CreateUserMessage message);
+	Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message);
+	Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message);
+	Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message);
+	Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message);
+	Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message);
+	Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message);
+	Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message);
+	Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message);
+	Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message);
+	Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message);
+	Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message);
+	Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message);
+	Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message);
 	Task<NoReply> HandleReply(ReplyMessage message);
 }
diff --git a/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs
index 18822c6..eef5392 100644
--- a/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs
+++ b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs
@@ -1,8 +1,13 @@
 using Phantom.Common.Messages.Web.BiDirectional;
+using Phantom.Common.Messages.Web.ToWeb;
 using Phantom.Utils.Rpc.Message;
 
 namespace Phantom.Common.Messages.Web;
 
 public interface IMessageToWebListener {
+	Task<NoReply> HandleRegisterWebResult(RegisterWebResultMessage message);
+	Task<NoReply> HandleRefreshAgents(RefreshAgentsMessage message);
+	Task<NoReply> HandleRefreshInstances(RefreshInstancesMessage message);
+	Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message);
 	Task<NoReply> HandleReply(ReplyMessage message);
 }
diff --git a/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj
index cad9d69..4310038 100644
--- a/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj
+++ b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj
@@ -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>
 
diff --git a/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs
new file mode 100644
index 0000000..9738705
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs
@@ -0,0 +1,17 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record ChangeUserRolesMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] Guid SubjectUserGuid,
+	[property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids,
+	[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids
+) : IMessageToController<ChangeUserRolesResult> {
+	public Task<ChangeUserRolesResult> Accept(IMessageToControllerListener listener) {
+		return listener.HandleChangeUserRoles(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs
new file mode 100644
index 0000000..0e9c607
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs
@@ -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 CreateOrUpdateAdministratorUserMessage(
+	[property: MemoryPackOrder(0)] string Username,
+	[property: MemoryPackOrder(1)] string Password
+) : IMessageToController<CreateOrUpdateAdministratorUserResult> {
+	public Task<CreateOrUpdateAdministratorUserResult> Accept(IMessageToControllerListener listener) {
+		return listener.HandleCreateOrUpdateAdministratorUser(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs
new file mode 100644
index 0000000..f10ca68
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs
@@ -0,0 +1,16 @@
+using MemoryPack;
+using Phantom.Common.Data.Instance;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Instance;
+
+namespace Phantom.Common.Messages.Web.ToController; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record CreateOrUpdateInstanceMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] InstanceConfiguration Configuration
+) : IMessageToController<InstanceActionResult<CreateOrUpdateInstanceResult>> {
+	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleCreateOrUpdateInstance(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs
new file mode 100644
index 0000000..85bd86e
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs
@@ -0,0 +1,15 @@
+using MemoryPack;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record CreateUserMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] string Username,
+	[property: MemoryPackOrder(2)] string Password
+) : IMessageToController<CreateUserResult> {
+	public Task<CreateUserResult> Accept(IMessageToControllerListener listener) {
+		return listener.HandleCreateUser(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs
new file mode 100644
index 0000000..35728eb
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs
@@ -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 DeleteUserMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] Guid SubjectUserGuid
+) : IMessageToController<DeleteUserResult> {
+	public Task<DeleteUserResult> Accept(IMessageToControllerListener listener) {
+		return listener.HandleDeleteUser(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs
new file mode 100644
index 0000000..4cead49
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs
@@ -0,0 +1,12 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Java;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetAgentJavaRuntimesMessage : IMessageToController<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> {
+	public Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetAgentJavaRuntimes(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs
new file mode 100644
index 0000000..ad42f4a
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs
@@ -0,0 +1,14 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.AuditLog;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetAuditLogMessage(
+	[property: MemoryPackOrder(0)] int Count
+) : IMessageToController<ImmutableArray<AuditLogItem>> {
+	public Task<ImmutableArray<AuditLogItem>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetAuditLog(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs
new file mode 100644
index 0000000..95e5b8d
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs
@@ -0,0 +1,14 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.EventLog;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetEventLogMessage(
+	[property: MemoryPackOrder(0)] int Count
+) : IMessageToController<ImmutableArray<EventLogItem>> {
+	public Task<ImmutableArray<EventLogItem>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetEventLog(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs
new file mode 100644
index 0000000..6e4b67a
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs
@@ -0,0 +1,12 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Minecraft;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetMinecraftVersionsMessage : IMessageToController<ImmutableArray<MinecraftVersion>> {
+	public Task<ImmutableArray<MinecraftVersion>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetMinecraftVersions(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs
new file mode 100644
index 0000000..d5b359e
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs
@@ -0,0 +1,12 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetRolesMessage : IMessageToController<ImmutableArray<RoleInfo>> {
+	public Task<ImmutableArray<RoleInfo>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetRoles(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs
new file mode 100644
index 0000000..b8d6575
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs
@@ -0,0 +1,13 @@
+using System.Collections.Immutable;
+using MemoryPack;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetUserRolesMessage(
+	[property: MemoryPackOrder(0)] ImmutableHashSet<Guid> UserGuids
+) : IMessageToController<ImmutableDictionary<Guid, ImmutableArray<Guid>>> {
+	public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetUserRoles(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs
new file mode 100644
index 0000000..05b6ee1
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs
@@ -0,0 +1,12 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record GetUsersMessage : IMessageToController<ImmutableArray<UserInfo>> {
+	public Task<ImmutableArray<UserInfo>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleGetUsers(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs
new file mode 100644
index 0000000..7b2ab9d
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs
@@ -0,0 +1,14 @@
+using MemoryPack;
+using Phantom.Common.Data.Replies;
+
+namespace Phantom.Common.Messages.Web.ToController; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record LaunchInstanceMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] Guid InstanceGuid
+) : IMessageToController<InstanceActionResult<LaunchInstanceResult>> {
+	public Task<InstanceActionResult<LaunchInstanceResult>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleLaunchInstance(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs
new file mode 100644
index 0000000..5b94a0b
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs
@@ -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 LogInMessage(
+	[property: MemoryPackOrder(0)] string Username,
+	[property: MemoryPackOrder(1)] string Password
+) : IMessageToController<LogInSuccess?> {
+	public Task<LogInSuccess?> Accept(IMessageToControllerListener listener) {
+		return listener.HandleLogIn(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs
new file mode 100644
index 0000000..6dfb29b
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs
@@ -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);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs
new file mode 100644
index 0000000..8295510
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs
@@ -0,0 +1,15 @@
+using MemoryPack;
+using Phantom.Common.Data.Replies;
+
+namespace Phantom.Common.Messages.Web.ToController; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record SendCommandToInstanceMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] Guid InstanceGuid,
+	[property: MemoryPackOrder(2)] string Command
+) : IMessageToController<InstanceActionResult<SendCommandToInstanceResult>> {
+	public Task<InstanceActionResult<SendCommandToInstanceResult>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleSendCommandToInstance(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs
new file mode 100644
index 0000000..adc986d
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs
@@ -0,0 +1,16 @@
+using MemoryPack;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+
+namespace Phantom.Common.Messages.Web.ToController; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record StopInstanceMessage(
+	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
+	[property: MemoryPackOrder(1)] Guid InstanceGuid,
+	[property: MemoryPackOrder(2)] MinecraftStopStrategy StopStrategy
+) : IMessageToController<InstanceActionResult<StopInstanceResult>> {
+	public Task<InstanceActionResult<StopInstanceResult>> Accept(IMessageToControllerListener listener) {
+		return listener.HandleStopInstance(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs
new file mode 100644
index 0000000..1142448
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs
@@ -0,0 +1,11 @@
+using MemoryPack;
+using Phantom.Utils.Rpc.Message;
+
+namespace Phantom.Common.Messages.Web.ToController;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record UnregisterWebMessage : IMessageToController {
+	public Task<NoReply> Accept(IMessageToControllerListener listener) {
+		return listener.HandleUnregisterWeb(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs
new file mode 100644
index 0000000..2e7cd19
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs
@@ -0,0 +1,15 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Utils.Rpc.Message;
+
+namespace Phantom.Common.Messages.Web.ToWeb;
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record InstanceOutputMessage(
+	[property: MemoryPackOrder(0)] Guid InstanceGuid,
+	[property: MemoryPackOrder(1)] ImmutableArray<string> Lines
+) : IMessageToWeb {
+	public Task<NoReply> Accept(IMessageToWebListener listener) {
+		return listener.HandleInstanceOutput(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs
new file mode 100644
index 0000000..807d438
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs
@@ -0,0 +1,15 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Utils.Rpc.Message;
+
+namespace Phantom.Common.Messages.Web.ToWeb; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record RefreshAgentsMessage(
+	[property: MemoryPackOrder(0)] ImmutableArray<AgentWithStats> Agents
+) : IMessageToWeb {
+	public Task<NoReply> Accept(IMessageToWebListener listener) {
+		return listener.HandleRefreshAgents(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs
new file mode 100644
index 0000000..d5ce8f8
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs
@@ -0,0 +1,15 @@
+using System.Collections.Immutable;
+using MemoryPack;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Utils.Rpc.Message;
+
+namespace Phantom.Common.Messages.Web.ToWeb; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record RefreshInstancesMessage(
+	[property: MemoryPackOrder(0)] ImmutableArray<Instance> Instances
+) : IMessageToWeb {
+	public Task<NoReply> Accept(IMessageToWebListener listener) {
+		return listener.HandleRefreshInstances(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs
new file mode 100644
index 0000000..807b2ec
--- /dev/null
+++ b/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs
@@ -0,0 +1,13 @@
+using MemoryPack;
+using Phantom.Utils.Rpc.Message;
+
+namespace Phantom.Common.Messages.Web.ToWeb; 
+
+[MemoryPackable(GenerateType.VersionTolerant)]
+public sealed partial record RegisterWebResultMessage(
+	[property: MemoryPackOrder(0)] bool Success
+) : IMessageToWeb {
+	public Task<NoReply> Accept(IMessageToWebListener listener) {
+		return listener.HandleRegisterWebResult(this);
+	}
+}
diff --git a/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs
index a3b82d5..6aed657 100644
--- a/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs
+++ b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs
@@ -1,5 +1,15 @@
-using Phantom.Common.Logging;
+using System.Collections.Immutable;
+using Phantom.Common.Data.Java;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Logging;
 using Phantom.Common.Messages.Web.BiDirectional;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Common.Messages.Web.ToWeb;
 using Phantom.Utils.Rpc.Message;
 
 namespace Phantom.Common.Messages.Web;
@@ -11,8 +21,30 @@ public static class WebMessageRegistries {
 	public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions();
 
 	static WebMessageRegistries() {
+		ToController.Add<RegisterWebMessage>(0);
+		ToController.Add<UnregisterWebMessage>(1);
+		ToController.Add<LogInMessage, LogInSuccess?>(2);
+		ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(3);
+		ToController.Add<CreateUserMessage, CreateUserResult>(4);
+		ToController.Add<DeleteUserMessage, DeleteUserResult>(5);
+		ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(6);
+		ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(7);
+		ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(8);
+		ToController.Add<ChangeUserRolesMessage, ChangeUserRolesResult>(9);
+		ToController.Add<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(10);
+		ToController.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(11);
+		ToController.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(12);
+		ToController.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(13);
+		ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(14);
+		ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(15);
+		ToController.Add<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(16);
+		ToController.Add<GetEventLogMessage, ImmutableArray<EventLogItem>>(17);
 		ToController.Add<ReplyMessage>(127);
 		
+		ToWeb.Add<RegisterWebResultMessage>(0);
+		ToWeb.Add<RefreshAgentsMessage>(1);
+		ToWeb.Add<RefreshInstancesMessage>(2);
+		ToWeb.Add<InstanceOutputMessage>(3);
 		ToWeb.Add<ReplyMessage>(127);
 	}
 
@@ -21,7 +53,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) {
diff --git a/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs
index 3f471a6..b4a15c6 100644
--- a/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs
+++ b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs
@@ -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);
diff --git a/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs b/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs
new file mode 100644
index 0000000..8cef848
--- /dev/null
+++ b/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs
@@ -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;
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs
index 09ea8ca..57b770e 100644
--- a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs
+++ b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs
@@ -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;
diff --git a/Controller/Phantom.Controller.Database/DatabaseMigrator.cs b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs
index 8a1ed1b..6deeb3d 100644
--- a/Controller/Phantom.Controller.Database/DatabaseMigrator.cs
+++ b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs
@@ -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...");
 
diff --git a/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs b/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs
index ff8e835..255e558 100644
--- a/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs
+++ b/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs
@@ -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;
 
diff --git a/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs b/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs
index 1b6dfe5..361bd03 100644
--- a/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs
+++ b/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs
@@ -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;
 
diff --git a/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs b/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs
index fe563b6..b76c361 100644
--- a/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs
+++ b/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs
@@ -1,5 +1,6 @@
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
+using Phantom.Common.Data.Web.Users;
 
 namespace Phantom.Controller.Database.Entities;
 
@@ -14,4 +15,8 @@ public sealed class RoleEntity {
 		RoleGuid = roleGuid;
 		Name = name;
 	}
+
+	public RoleInfo ToRoleInfo() {
+		return new RoleInfo(RoleGuid, Name);
+	}
 }
diff --git a/Controller/Phantom.Controller.Database/Entities/UserEntity.cs b/Controller/Phantom.Controller.Database/Entities/UserEntity.cs
index f590523..da4b72c 100644
--- a/Controller/Phantom.Controller.Database/Entities/UserEntity.cs
+++ b/Controller/Phantom.Controller.Database/Entities/UserEntity.cs
@@ -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);
 	}
 }
diff --git a/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs b/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs
deleted file mode 100644
index 2005202..0000000
--- a/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace Phantom.Controller.Database.Enums;
-
-public enum EventLogSubjectType {
-	Instance
-}
diff --git a/Controller/Phantom.Controller.Database/IDatabaseProvider.cs b/Controller/Phantom.Controller.Database/IDatabaseProvider.cs
deleted file mode 100644
index bb8a395..0000000
--- a/Controller/Phantom.Controller.Database/IDatabaseProvider.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace Phantom.Controller.Database;
-
-public interface IDatabaseProvider {
-	ApplicationDbContext Provide();
-}
diff --git a/Controller/Phantom.Controller.Database/IDbContextProvider.cs b/Controller/Phantom.Controller.Database/IDbContextProvider.cs
new file mode 100644
index 0000000..33cd1f1
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/IDbContextProvider.cs
@@ -0,0 +1,6 @@
+namespace Phantom.Controller.Database;
+
+public interface IDbContextProvider {
+	ApplicationDbContext Eager();
+	ILazyDbContext Lazy();
+}
diff --git a/Controller/Phantom.Controller.Database/ILazyDbContext.cs b/Controller/Phantom.Controller.Database/ILazyDbContext.cs
new file mode 100644
index 0000000..c4ca69b
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/ILazyDbContext.cs
@@ -0,0 +1,5 @@
+namespace Phantom.Controller.Database;
+
+public interface ILazyDbContext : IAsyncDisposable {
+	ApplicationDbContext Ctx { get; }
+}
diff --git a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj
index bf57ab9..a5536e2 100644
--- a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj
+++ b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj
@@ -11,10 +11,12 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
+    <PackageReference Include="System.Linq.Async" />
   </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" />
   </ItemGroup>
 
diff --git a/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs
new file mode 100644
index 0000000..2721705
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs
@@ -0,0 +1,88 @@
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Controller.Database.Entities;
+
+namespace Phantom.Controller.Database.Repositories;
+
+sealed partial class AuditLogRepository {
+	public sealed class ItemWriter {
+		private readonly ILazyDbContext db;
+		private readonly Guid? currentUserGuid;
+
+		internal ItemWriter(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 void UserLoggedIn(UserEntity user) {
+			AddItem(AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
+		}
+
+		public void UserLoggedOut(Guid userGuid) {
+			AddItem(AuditLogEventType.UserLoggedOut, userGuid.ToString());
+		}
+
+		public void AdministratorUserCreated(UserEntity user) {
+			AddItem(AuditLogEventType.AdministratorUserCreated, user.UserGuid.ToString());
+		}
+
+		public void AdministratorUserModified(UserEntity user) {
+			AddItem(AuditLogEventType.AdministratorUserCreated, user.UserGuid.ToString());
+		}
+
+		public void UserCreated(UserEntity user) {
+			AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
+		}
+
+		public void UserPasswordChanged(UserEntity user) {
+			AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
+		}
+
+		public void UserRolesChanged(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 UserDeleted(UserEntity user) {
+			AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
+				{ "username", user.Name }
+			});
+		}
+
+		public void InstanceCreated(Guid instanceGuid) {
+			AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
+		}
+
+		public void InstanceEdited(Guid instanceGuid) {
+			AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString());
+		}
+
+		public void InstanceLaunched(Guid instanceGuid) {
+			AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString());
+		}
+
+		public void InstanceCommandExecuted(Guid instanceGuid, string command) {
+			AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> {
+				{ "command", command }
+			});
+		}
+
+		public void InstanceStopped(Guid instanceGuid, int stopInSeconds) {
+			AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> {
+				{ "stop_in_seconds", stopInSeconds.ToString() }
+			});
+		}
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs
new file mode 100644
index 0000000..86cace6
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+using Microsoft.EntityFrameworkCore;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Utils.Collections;
+
+namespace Phantom.Controller.Database.Repositories;
+
+public sealed partial class AuditLogRepository {
+	private readonly ILazyDbContext db;
+
+	public AuditLogRepository(ILazyDbContext db) {
+		this.db = db;
+	}
+
+	public Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
+		return db.Ctx
+		         .AuditLog
+		         .Include(static entity => entity.User)
+		         .AsQueryable()
+		         .OrderByDescending(static entity => entity.UtcTime)
+		         .Take(count)
+		         .AsAsyncEnumerable()
+		         .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User?.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString()))
+		         .ToImmutableArrayAsync(cancellationToken);
+	}
+	
+	public ItemWriter Writer(Guid? currentUserGuid) {
+		return new ItemWriter(db, currentUserGuid);
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs
new file mode 100644
index 0000000..b861511
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+using Microsoft.EntityFrameworkCore;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Controller.Database.Entities;
+using Phantom.Utils.Collections;
+
+namespace Phantom.Controller.Database.Repositories;
+
+public sealed class EventLogRepository {
+	private readonly ILazyDbContext db;
+
+	public EventLogRepository(ILazyDbContext db) {
+		this.db = db;
+	}
+
+	public void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
+		db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
+	}
+	
+	public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
+		return db.Ctx
+		         .EventLog
+		         .AsQueryable()
+		         .OrderByDescending(static entity => entity.UtcTime)
+		         .Take(count)
+		         .AsAsyncEnumerable()
+		         .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString()))
+		         .ToImmutableArrayAsync(cancellationToken);
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs b/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs
new file mode 100644
index 0000000..735f781
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs
@@ -0,0 +1,50 @@
+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<ImmutableArray<RoleEntity>> GetAll() {
+		return db.Ctx.Roles.AsAsyncEnumerable().ToImmutableArrayAsync();
+	}
+	
+	public Task<ImmutableDictionary<Guid, RoleEntity>> GetByGuids(ImmutableHashSet<Guid> guids) {
+		return db.Ctx.Roles
+		         .Where(role => guids.Contains(role.RoleGuid))
+		         .AsAsyncEnumerable()
+		         .ToImmutableDictionaryAsync(static role => role.RoleGuid, static role => role);
+	}
+
+	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;
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs
new file mode 100644
index 0000000..59b94c8
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs
@@ -0,0 +1,109 @@
+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;
+
+	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);
+
+		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);
+
+		return Result.Ok<SetUserPasswordError>();
+	}
+
+	public void DeleteUser(UserEntity user) {
+		db.Ctx.Users.Remove(user);
+	}
+}
diff --git a/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs b/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs
new file mode 100644
index 0000000..19e3ead
--- /dev/null
+++ b/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs
@@ -0,0 +1,72 @@
+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 async Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetRoleGuidsByUserGuid(ImmutableHashSet<Guid> userGuids) {
+		var result = await db.Ctx.UserRoles
+		                     .Where(ur => userGuids.Contains(ur.UserGuid))
+		                     .GroupBy(static ur => ur.UserGuid, static ur => ur.RoleGuid)
+		                     .AsAsyncEnumerable()
+		                     .ToImmutableDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
+		
+		foreach (var userGuid in userGuids) {
+			if (!result.ContainsKey(userGuid)) {
+				result = result.Add(userGuid, ImmutableArray<Guid>.Empty);
+			}
+		}
+		
+		return result;
+	}
+	
+	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;
+		}
+	}
+}
diff --git a/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs b/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs
index 8fd5ed4..41c2e0f 100644
--- a/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs
+++ b/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs
@@ -1,9 +1,9 @@
 namespace Phantom.Controller.Rpc;
 
-sealed class RpcClientConnectionClosedEventArgs : EventArgs {
-	public uint RoutingId { get; }
+public sealed class RpcClientConnectionClosedEventArgs : EventArgs {
+	internal uint RoutingId { get; }
 
-	public RpcClientConnectionClosedEventArgs(uint routingId) {
+	internal RpcClientConnectionClosedEventArgs(uint routingId) {
 		RoutingId = routingId;
 	}
 }
diff --git a/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs b/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs
index 8be4f1a..895c205 100644
--- a/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs
+++ b/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs
@@ -19,7 +19,7 @@ public sealed class RpcConnectionToClient<TListener> {
 	}
 
 	internal event EventHandler<RpcClientConnectionClosedEventArgs>? Closed;
-	private bool isClosed;
+	public bool IsClosed { get; private set; }
 
 	internal RpcConnectionToClient(ServerSocket socket, uint routingId, MessageRegistry<TListener> messageRegistry, MessageReplyTracker messageReplyTracker) {
 		this.socket = socket;
@@ -33,16 +33,22 @@ public sealed class RpcConnectionToClient<TListener> {
 	}
 
 	public void Close() {
+		bool hasClosed = false;
+		
 		lock (this) {
-			if (!isClosed) {
-				isClosed = true;
-				Closed?.Invoke(this, new RpcClientConnectionClosedEventArgs(routingId));
+			if (!IsClosed) {
+				IsClosed = true;
+				hasClosed = true;
 			}
 		}
+
+		if (hasClosed) {
+			Closed?.Invoke(this, new RpcClientConnectionClosedEventArgs(routingId));
+		}
 	}
 
 	public async Task Send<TMessage>(TMessage message) where TMessage : IMessage<TListener, NoReply> {
-		if (isClosed) {
+		if (IsClosed) {
 			return;
 		}
 		
@@ -53,7 +59,7 @@ public sealed class RpcConnectionToClient<TListener> {
 	}
 
 	public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
-		if (isClosed) {
+		if (IsClosed) {
 			return null;
 		}
 		
diff --git a/Controller/Phantom.Controller.Services/Agents/Agent.cs b/Controller/Phantom.Controller.Services/Agents/Agent.cs
index 76c649b..59ee355 100644
--- a/Controller/Phantom.Controller.Services/Agents/Agent.cs
+++ b/Controller/Phantom.Controller.Services/Agents/Agent.cs
@@ -20,8 +20,6 @@ public sealed record Agent(
 	public bool IsOnline { get; internal init; }
 	public bool IsOffline => !IsOnline;
 
-	public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory;
-	
 	internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {}
 
 	internal Agent AsOnline(DateTimeOffset lastPing) => this with {
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs
index bcc2043..9083a4c 100644
--- a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs
+++ b/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs
@@ -4,7 +4,7 @@ using Phantom.Utils.Collections;
 
 namespace Phantom.Controller.Services.Agents;
 
-public sealed class AgentJavaRuntimesManager {
+sealed class AgentJavaRuntimesManager {
 	private readonly RwLockedDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> runtimes = new (LockRecursionPolicy.NoRecursion);
 
 	public ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> All => runtimes.ToImmutable();
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
index 111c877..884c7ce 100644
--- a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
+++ b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs
@@ -11,11 +11,11 @@ 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;
 
-public sealed class AgentManager {
+sealed class AgentManager {
 	private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>();
 
 	private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
@@ -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;
diff --git a/Controller/Phantom.Controller.Services/Agents/AgentStats.cs b/Controller/Phantom.Controller.Services/Agents/AgentStats.cs
deleted file mode 100644
index 0e1d8c7..0000000
--- a/Controller/Phantom.Controller.Services/Agents/AgentStats.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Phantom.Common.Data;
-
-namespace Phantom.Controller.Services.Agents;
-
-public sealed record AgentStats(
-	int RunningInstanceCount,
-	RamAllocationUnits RunningInstanceMemory
-);
diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs b/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs
deleted file mode 100644
index 3e46965..0000000
--- a/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs
+++ /dev/null
@@ -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() }
-		});
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs b/Controller/Phantom.Controller.Services/Audit/AuditLog.cs
deleted file mode 100644
index 5b98386..0000000
--- a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs
+++ /dev/null
@@ -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);
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs b/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs
deleted file mode 100644
index f38c97a..0000000
--- a/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using System.Text.Json;
-using Phantom.Controller.Database.Enums;
-
-namespace Phantom.Controller.Services.Audit;
-
-public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data);
diff --git a/Controller/Phantom.Controller.Services/ControllerServices.cs b/Controller/Phantom.Controller.Services/ControllerServices.cs
index 20e1d62..d228115 100644
--- a/Controller/Phantom.Controller.Services/ControllerServices.cs
+++ b/Controller/Phantom.Controller.Services/ControllerServices.cs
@@ -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,50 +17,58 @@ 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; }
 	private InstanceManager InstanceManager { get; }
 	private InstanceLogManager InstanceLogManager { get; }
-	
+	private EventLogManager EventLogManager { get; }
+
 	private UserManager UserManager { get; }
 	private RoleManager RoleManager { get; }
-	private UserRoleManager UserRoleManager { get; }
 	private PermissionManager PermissionManager { get; }
 
-	private readonly IDatabaseProvider databaseProvider;
+	private UserRoleManager UserRoleManager { get; }
+	private UserLoginManager UserLoginManager { get; }
+	private AuditLogManager AuditLogManager { get; }
+	
+	private readonly IDbContextProvider dbProvider;
+	private readonly AuthToken webAuthToken;
 	private readonly CancellationToken cancellationToken;
 	
-	public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) {
+	public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, AuthToken webAuthToken, 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.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.UserRoleManager = new UserRoleManager(dbProvider);
+		this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager);
+		this.AuditLogManager = new AuditLogManager(dbProvider);
+		this.EventLogManager = new EventLogManager(dbProvider, TaskManager, shutdownCancellationToken);
 		
-		this.databaseProvider = databaseProvider;
+		this.dbProvider = dbProvider;
+		this.webAuthToken = webAuthToken;
 		this.cancellationToken = shutdownCancellationToken;
 	}
 
 	public AgentMessageListener CreateAgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection) {
-		return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLog, cancellationToken);
+		return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLogManager, cancellationToken);
 	}
 
 	public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
-		return new WebMessageListener(connection);
+		return new WebMessageListener(connection, webAuthToken, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, MinecraftVersions, EventLogManager, TaskManager);
 	}
 
 	public async Task Initialize() {
-		await DatabaseMigrator.Run(databaseProvider, cancellationToken);
+		await DatabaseMigrator.Run(dbProvider, cancellationToken);
 		await PermissionManager.Initialize();
 		await RoleManager.Initialize();
 		await AgentManager.Initialize();
diff --git a/Controller/Phantom.Controller.Services/Events/EventLog.cs b/Controller/Phantom.Controller.Services/Events/EventLog.cs
deleted file mode 100644
index f2e86b9..0000000
--- a/Controller/Phantom.Controller.Services/Events/EventLog.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.Collections.Immutable;
-using Microsoft.EntityFrameworkCore;
-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 TaskManager taskManager;
-	private readonly CancellationToken cancellationToken;
-	
-	public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
-		this.databaseProvider = databaseProvider;
-		this.taskManager = taskManager;
-		this.cancellationToken = cancellationToken;
-	}
-
-	private async Task AddEntityToDatabase(EventLogEntity logEntity) {
-		await using var ctx = databaseProvider.Provide();
-		ctx.EventLog.Add(logEntity);
-		await ctx.SaveChangesAsync(cancellationToken);
-	}
-
-	private void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
-		var logEntity = new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra);
-		taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity));
-	}
-
-	public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
-		await using var ctx = databaseProvider.Provide();
-		return await ctx.EventLog
-		                .AsQueryable()
-		                .OrderByDescending(static entity => entity.UtcTime)
-		                .Take(count)
-		                .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
-		                .AsAsyncEnumerable()
-		                .ToImmutableArrayAsync(cancellationToken);
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Events/EventLogItem.cs b/Controller/Phantom.Controller.Services/Events/EventLogItem.cs
deleted file mode 100644
index 7e9da73..0000000
--- a/Controller/Phantom.Controller.Services/Events/EventLogItem.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using System.Text.Json;
-using Phantom.Controller.Database.Enums;
-
-namespace Phantom.Controller.Services.Events;
-
-public sealed record EventLogItem(DateTime UtcTime, Guid? AgentGuid, EventLogEventType EventType, EventLogSubjectType SubjectType, string SubjectId, JsonDocument? Data);
diff --git a/Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs b/Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs
similarity index 64%
rename from Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs
rename to Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs
index 915aea8..790a167 100644
--- a/Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs
+++ b/Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs
@@ -1,23 +1,23 @@
 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;
 
-public sealed partial class EventLog {
+sealed partial class EventLogManager {
 	internal IInstanceEventVisitor CreateInstanceEventVisitor(Guid eventGuid, DateTime utcTime, Guid agentGuid, Guid instanceGuid) {
 		return new InstanceEventVisitor(this, utcTime, eventGuid, agentGuid, instanceGuid);
 	}
 
 	private sealed class InstanceEventVisitor : IInstanceEventVisitor {
-		private readonly EventLog eventLog;
+		private readonly EventLogManager eventLogManager;
 		private readonly Guid eventGuid;
 		private readonly DateTime utcTime;
 		private readonly Guid agentGuid;
 		private readonly Guid instanceGuid;
 
-		public InstanceEventVisitor(EventLog eventLog, DateTime utcTime, Guid eventGuid, Guid agentGuid, Guid instanceGuid) {
-			this.eventLog = eventLog;
+		public InstanceEventVisitor(EventLogManager eventLogManager, DateTime utcTime, Guid eventGuid, Guid agentGuid, Guid instanceGuid) {
+			this.eventLogManager = eventLogManager;
 			this.eventGuid = eventGuid;
 			this.utcTime = utcTime;
 			this.agentGuid = agentGuid;
@@ -25,21 +25,21 @@ public sealed partial class EventLog {
 		}
 
 		public void OnLaunchSucceeded(InstanceLaunchSuccededEvent e) {
-			eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchSucceded, instanceGuid.ToString());
+			eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchSucceded, instanceGuid.ToString());
 		}
 
 		public void OnLaunchFailed(InstanceLaunchFailedEvent e) {
-			eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchFailed, instanceGuid.ToString(), new Dictionary<string, object?> {
+			eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchFailed, instanceGuid.ToString(), new Dictionary<string, object?> {
 				{ "reason", e.Reason.ToString() }
 			});
 		}
 
 		public void OnCrashed(InstanceCrashedEvent e) {
-			eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceCrashed, instanceGuid.ToString());
+			eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceCrashed, instanceGuid.ToString());
 		}
 
 		public void OnStopped(InstanceStoppedEvent e) {
-			eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceStopped, instanceGuid.ToString());
+			eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceStopped, instanceGuid.ToString());
 		}
 
 		public void OnBackupCompleted(InstanceBackupCompletedEvent e) {
@@ -59,7 +59,7 @@ public sealed partial class EventLog {
 				dictionary["warnings"] = e.Warnings.ListFlags().Select(static warning => warning.ToString()).ToArray();
 			}
 
-			eventLog.AddItem(eventGuid, utcTime, agentGuid, eventType, instanceGuid.ToString(), dictionary.Count == 0 ? null : dictionary);
+			eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, eventType, instanceGuid.ToString(), dictionary.Count == 0 ? null : dictionary);
 		}
 	}
 }
diff --git a/Controller/Phantom.Controller.Services/Events/EventLogManager.cs b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs
new file mode 100644
index 0000000..a6d74ee
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs
@@ -0,0 +1,34 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Controller.Database;
+using Phantom.Controller.Database.Repositories;
+using Phantom.Utils.Tasks;
+
+namespace Phantom.Controller.Services.Events; 
+
+sealed partial class EventLogManager {
+	private readonly IDbContextProvider dbProvider;
+	private readonly TaskManager taskManager;
+	private readonly CancellationToken cancellationToken;
+
+	public EventLogManager(IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
+		this.dbProvider = dbProvider;
+		this.taskManager = taskManager;
+		this.cancellationToken = cancellationToken;
+	}
+
+	public void EnqueueItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
+		taskManager.Run("Store event log item to database", () => AddItem(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
+	}
+	
+	public async Task AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
+		await using var db = dbProvider.Lazy();
+		new EventLogRepository(db).AddItem(eventGuid, utcTime, agentGuid, eventType, subjectId, extra);
+		await db.Ctx.SaveChangesAsync(cancellationToken);
+	}
+
+	public async Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count) {
+		await using var db = dbProvider.Lazy();
+		return await new EventLogRepository(db).GetMostRecentItems(count, cancellationToken);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs b/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs
deleted file mode 100644
index d11da1c..0000000
--- a/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace Phantom.Controller.Services.Instances;
-
-public enum AddOrEditInstanceResult : byte {
-	UnknownError,
-	Success,
-	InstanceNameMustNotBeEmpty,
-	InstanceMemoryMustNotBeZero,
-	MinecraftVersionDownloadInfoNotFound,
-	AgentNotFound
-}
-
-public static class AddOrEditInstanceResultExtensions {
-	public static string ToSentence(this AddOrEditInstanceResult reason) {
-		return reason switch {
-			AddOrEditInstanceResult.Success                              => "Success.",
-			AddOrEditInstanceResult.InstanceNameMustNotBeEmpty           => "Instance name must not be empty.",
-			AddOrEditInstanceResult.InstanceMemoryMustNotBeZero          => "Memory must not be 0 MB.",
-			AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.",
-			AddOrEditInstanceResult.AgentNotFound                        => "Agent not found.",
-			_                                                            => "Unknown error."
-		};
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Instances/Instance.cs b/Controller/Phantom.Controller.Services/Instances/Instance.cs
deleted file mode 100644
index 687e893..0000000
--- a/Controller/Phantom.Controller.Services/Instances/Instance.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Phantom.Common.Data.Instance;
-
-namespace Phantom.Controller.Services.Instances;
-
-public sealed record Instance(
-	InstanceConfiguration Configuration,
-	IInstanceStatus Status,
-	bool LaunchAutomatically
-) {
-	internal Instance(InstanceConfiguration configuration, bool launchAutomatically = false) : this(configuration, InstanceStatus.Offline, launchAutomatically) {}
-}
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs
index d50b3af..3832039 100644
--- a/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs
+++ b/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs
@@ -1,44 +1,13 @@
-using System.Collections.Concurrent;
 using System.Collections.Immutable;
-using Phantom.Common.Logging;
-using Phantom.Utils.Collections;
-using Phantom.Utils.Events;
-using ILogger = Serilog.ILogger;
 
 namespace Phantom.Controller.Services.Instances;
 
-public sealed class InstanceLogManager {
-	private const int RetainedLines = 1000;
+sealed class InstanceLogManager {
+	public sealed record Event(Guid InstanceGuid, ImmutableArray<string> Lines);
 	
-	private readonly ConcurrentDictionary<Guid, ObservableInstanceLogs> logsByInstanceGuid = new ();
-	
-	private ObservableInstanceLogs GetInstanceLogs(Guid instanceGuid) {
-		return logsByInstanceGuid.GetOrAdd(instanceGuid, static _ => new ObservableInstanceLogs(PhantomLogger.Create<InstanceManager, ObservableInstanceLogs>()));
-	}
+	public event EventHandler<Event>? LogsReceived; 
 
-	internal void AddLines(Guid instanceGuid, ImmutableArray<string> lines) {
-		GetInstanceLogs(instanceGuid).Add(lines);
-	}
-
-	public EventSubscribers<RingBuffer<string>> GetSubs(Guid instanceGuid) {
-		return GetInstanceLogs(instanceGuid).Subs;
-	}
-	
-	private sealed class ObservableInstanceLogs : ObservableState<RingBuffer<string>> {
-		private readonly RingBuffer<string> log = new (RetainedLines);
-
-		public ObservableInstanceLogs(ILogger logger) : base(logger) {}
-
-		public void Add(ImmutableArray<string> lines) {
-			foreach (var line in lines) {
-				log.Add(InstanceLogHtmlFilters.Process(line));
-			}
-
-			Update();
-		}
-
-		protected override RingBuffer<string> GetData() {
-			return log;
-		}
+	internal void ReceiveLines(Guid instanceGuid, ImmutableArray<string> lines) {
+		LogsReceived?.Invoke(this, new Event(instanceGuid, lines));
 	}
 }
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs
index cb042be..b4fb771 100644
--- a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs
+++ b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs
@@ -4,20 +4,23 @@ using Phantom.Common.Data;
 using Phantom.Common.Data.Instance;
 using Phantom.Common.Data.Minecraft;
 using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Data.Web.Minecraft;
 using Phantom.Common.Logging;
 using Phantom.Common.Messages.Agent;
 using Phantom.Common.Messages.Agent.ToAgent;
 using Phantom.Controller.Database;
 using Phantom.Controller.Database.Entities;
+using Phantom.Controller.Database.Repositories;
 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 +29,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,
@@ -54,53 +57,53 @@ public sealed class InstanceManager {
 				JvmArgumentsHelper.Split(entity.JvmArguments)
 			);
 
-			var instance = new Instance(configuration, entity.LaunchAutomatically);
+			var instance = Instance.Offline(configuration, entity.LaunchAutomatically);
 			instances.ByGuid[instance.Configuration.InstanceGuid] = instance;
 		}
 	}
 
 	[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
-	public async Task<InstanceActionResult<AddOrEditInstanceResult>> AddOrEditInstance(InstanceConfiguration configuration) {
+	public async Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid auditLogUserGuid, InstanceConfiguration configuration) {
 		var agent = agentManager.GetAgent(configuration.AgentGuid);
 		if (agent == null) {
-			return InstanceActionResult.Concrete(AddOrEditInstanceResult.AgentNotFound);
+			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.AgentNotFound);
 		}
 
 		if (string.IsNullOrWhiteSpace(configuration.InstanceName)) {
-			return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceNameMustNotBeEmpty);
+			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty);
 		}
 		
 		if (configuration.MemoryAllocation <= RamAllocationUnits.Zero) {
-			return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceMemoryMustNotBeZero);
+			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero);
 		}
 		
 		var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken);
 		if (serverExecutableInfo == null) {
-			return InstanceActionResult.Concrete(AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound);
+			return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound);
 		}
 
-		InstanceActionResult<AddOrEditInstanceResult> result;
+		InstanceActionResult<CreateOrUpdateInstanceResult> result;
 		bool isNewInstance;
 
 		await modifyInstancesSemaphore.WaitAsync(cancellationToken);
 		try {
 			isNewInstance = !instances.ByGuid.TryReplace(configuration.InstanceGuid, instance => instance with { Configuration = configuration });
 			if (isNewInstance) {
-				instances.ByGuid.TryAdd(configuration.InstanceGuid, new Instance(configuration));
+				instances.ByGuid.TryAdd(configuration.InstanceGuid, Instance.Offline(configuration));
 			}
 
 			var message = new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo));
 			var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, message, TimeSpan.FromSeconds(10));
 			
 			result = reply.DidNotReplyIfNull().Map(static result => result switch {
-				ConfigureInstanceResult.Success => AddOrEditInstanceResult.Success,
-				_                               => AddOrEditInstanceResult.UnknownError
+				ConfigureInstanceResult.Success => CreateOrUpdateInstanceResult.Success,
+				_                               => CreateOrUpdateInstanceResult.UnknownError
 			});
 			
-			if (result.Is(AddOrEditInstanceResult.Success)) {
-				await using var ctx = databaseProvider.Provide();
-				InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
-
+			if (result.Is(CreateOrUpdateInstanceResult.Success)) {
+				await using var db = dbProvider.Lazy();
+				
+				InstanceEntity entity = db.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
 				entity.AgentGuid = configuration.AgentGuid;
 				entity.InstanceName = configuration.InstanceName;
 				entity.ServerPort = configuration.ServerPort;
@@ -110,8 +113,16 @@ public sealed class InstanceManager {
 				entity.MemoryAllocation = configuration.MemoryAllocation;
 				entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
 				entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
+				
+				var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserGuid);
+				if (isNewInstance) {
+					auditLogWriter.InstanceCreated(configuration.InstanceGuid);
+				}
+				else {
+					auditLogWriter.InstanceEdited(configuration.InstanceGuid);
+				}
 
-				await ctx.SaveChangesAsync(cancellationToken);
+				await db.Ctx.SaveChangesAsync(cancellationToken);
 			}
 			else if (isNewInstance) {
 				instances.ByGuid.Remove(configuration.InstanceGuid);
@@ -120,7 +131,7 @@ public sealed class InstanceManager {
 			modifyInstancesSemaphore.Release();
 		}
 		
-		if (result.Is(AddOrEditInstanceResult.Success)) {
+		if (result.Is(CreateOrUpdateInstanceResult.Success)) {
 			if (isNewInstance) {
 				Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name);
 			}
@@ -130,24 +141,16 @@ public sealed class InstanceManager {
 		}
 		else {
 			if (isNewInstance) {
-				Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
+				Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
 			}
 			else {
-				Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
+				Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
 			}
 		}
 
 		return result;
 	}
 
-	public ImmutableDictionary<Guid, string> GetInstanceNames() {
-		return instances.ByGuid.ToImmutable<string>(static instance => instance.Configuration.InstanceName);
-	}
-
-	public InstanceConfiguration? GetInstanceConfiguration(Guid instanceGuid) {
-		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance.Configuration : null;
-	}
-
 	internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) {
 		instances.ByGuid.TryReplace(instanceGuid, instance => instance with { Status = instanceStatus });
 	}
@@ -165,42 +168,52 @@ public sealed class InstanceManager {
 		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? await SendInstanceActionMessage<TMessage, TReply>(instance, message) : InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist);
 	}
 
-	public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid instanceGuid) {
+	public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid auditLogUserGuid, Guid instanceGuid) {
 		var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instanceGuid, new LaunchInstanceMessage(instanceGuid));
 		if (result.Is(LaunchInstanceResult.LaunchInitiated)) {
-			await SetInstanceShouldLaunchAutomatically(instanceGuid, true);
+			await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, true, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceLaunched(instanceGuid));
 		}
 
 		return result;
 	}
 
-	public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
+	public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid auditLogUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
 		var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instanceGuid, new StopInstanceMessage(instanceGuid, stopStrategy));
 		if (result.Is(StopInstanceResult.StopInitiated)) {
-			await SetInstanceShouldLaunchAutomatically(instanceGuid, false);
+			await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, false, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceStopped(instanceGuid, stopStrategy.Seconds));
 		}
 
 		return result;
 	}
 
-	private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) {
+	private async Task HandleInstanceManuallyLaunchedOrStopped(Guid instanceGuid, bool wasLaunched, Guid auditLogUserGuid, Action<AuditLogRepository.ItemWriter> addAuditEvent) {
 		await modifyInstancesSemaphore.WaitAsync(cancellationToken);
 		try {
-			instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
+			instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = wasLaunched });
 
-			await using var ctx = databaseProvider.Provide();
-			var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken);
+			await using var db = dbProvider.Lazy();
+			var entity = await db.Ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken);
 			if (entity != null) {
-				entity.LaunchAutomatically = shouldLaunchAutomatically;
-				await ctx.SaveChangesAsync(cancellationToken);
+				entity.LaunchAutomatically = wasLaunched;
+				addAuditEvent(new AuditLogRepository(db).Writer(auditLogUserGuid));
+				await db.Ctx.SaveChangesAsync(cancellationToken);
 			}
 		} finally {
 			modifyInstancesSemaphore.Release();
 		}
 	}
 
-	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
-		return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
+	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid auditLogUserId, Guid instanceGuid, string command) {
+		var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
+		if (result.Is(SendCommandToInstanceResult.Success)) {
+			await using var db = dbProvider.Lazy();
+			var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserId);
+			
+			auditLogWriter.InstanceCommandExecuted(instanceGuid, command);
+			await db.Ctx.SaveChangesAsync(cancellationToken);
+		}
+
+		return result;
 	}
 
 	internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
diff --git a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
index f227ef4..d400e20 100644
--- a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
+++ b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj
@@ -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" />
diff --git a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
index d0b5ec6..9f17a07 100644
--- a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
+++ b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs
@@ -19,18 +19,18 @@ public sealed class AgentMessageListener : IMessageToControllerListener {
 	private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
 	private readonly InstanceManager instanceManager;
 	private readonly InstanceLogManager instanceLogManager;
-	private readonly EventLog eventLog;
+	private readonly EventLogManager eventLogManager;
 	private readonly CancellationToken cancellationToken;
 
 	private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>();
 
-	internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog, CancellationToken cancellationToken) {
+	internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager, CancellationToken cancellationToken) {
 		this.connection = connection;
 		this.agentManager = agentManager;
 		this.agentJavaRuntimesManager = agentJavaRuntimesManager;
 		this.instanceManager = instanceManager;
 		this.instanceLogManager = instanceLogManager;
-		this.eventLog = eventLog;
+		this.eventLogManager = eventLogManager;
 		this.cancellationToken = cancellationToken;
 	}
 
@@ -83,12 +83,12 @@ public sealed class AgentMessageListener : IMessageToControllerListener {
 	}
 
 	public async Task<NoReply> HandleReportInstanceEvent(ReportInstanceEventMessage message) {
-		message.Event.Accept(eventLog.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, await WaitForAgentGuid(), message.InstanceGuid));
+		message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, await WaitForAgentGuid(), message.InstanceGuid));
 		return NoReply.Instance;
 	}
 
 	public Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message) {
-		instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
+		instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines);
 		return Task.FromResult(NoReply.Instance);
 	}
 
diff --git a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
index 0b109cf..510a32b 100644
--- a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
+++ b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs
@@ -1,15 +1,199 @@
-using Phantom.Common.Messages.Web;
+using System.Collections.Immutable;
+using Phantom.Common.Data;
+using Phantom.Common.Data.Java;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Logging;
+using Phantom.Common.Messages.Web;
 using Phantom.Common.Messages.Web.BiDirectional;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Common.Messages.Web.ToWeb;
+using Phantom.Controller.Minecraft;
 using Phantom.Controller.Rpc;
+using Phantom.Controller.Services.Agents;
+using Phantom.Controller.Services.Events;
+using Phantom.Controller.Services.Instances;
+using Phantom.Controller.Services.Users;
 using Phantom.Utils.Rpc.Message;
+using Phantom.Utils.Tasks;
+using Serilog;
 
 namespace Phantom.Controller.Services.Rpc;
 
 public sealed class WebMessageListener : IMessageToControllerListener {
-	private readonly RpcConnectionToClient<IMessageToWebListener> connection;
+	private static readonly ILogger Logger = PhantomLogger.Create<WebMessageListener>();
 	
-	internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
+	private readonly RpcConnectionToClient<IMessageToWebListener> connection;
+	private readonly AuthToken authToken;
+	private readonly UserManager userManager;
+	private readonly RoleManager roleManager;
+	private readonly UserRoleManager userRoleManager;
+	private readonly UserLoginManager userLoginManager;
+	private readonly AuditLogManager auditLogManager;
+	private readonly AgentManager agentManager;
+	private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
+	private readonly InstanceManager instanceManager;
+	private readonly InstanceLogManager instanceLogManager;
+	private readonly MinecraftVersions minecraftVersions;
+	private readonly EventLogManager eventLogManager;
+	private readonly TaskManager taskManager;
+
+	internal WebMessageListener(
+		RpcConnectionToClient<IMessageToWebListener> connection,
+		AuthToken authToken,
+		UserManager userManager,
+		RoleManager roleManager,
+		UserRoleManager userRoleManager,
+		UserLoginManager userLoginManager,
+		AuditLogManager auditLogManager,
+		AgentManager agentManager,
+		AgentJavaRuntimesManager agentJavaRuntimesManager,
+		InstanceManager instanceManager,
+		InstanceLogManager instanceLogManager,
+		MinecraftVersions minecraftVersions,
+		EventLogManager eventLogManager,
+		TaskManager taskManager
+	) {
 		this.connection = connection;
+		this.authToken = authToken;
+		this.userManager = userManager;
+		this.roleManager = roleManager;
+		this.userRoleManager = userRoleManager;
+		this.userLoginManager = userLoginManager;
+		this.auditLogManager = auditLogManager;
+		this.agentManager = agentManager;
+		this.agentJavaRuntimesManager = agentJavaRuntimesManager;
+		this.instanceManager = instanceManager;
+		this.instanceLogManager = instanceLogManager;
+		this.minecraftVersions = minecraftVersions;
+		this.eventLogManager = eventLogManager;
+		this.taskManager = taskManager;
+	}
+
+	private void OnConnectionReady() {
+		lock (this) {
+			agentManager.AgentsChanged.Subscribe(this, HandleAgentsChanged);
+			instanceManager.InstancesChanged.Subscribe(this, HandleInstancesChanged);
+			instanceLogManager.LogsReceived += HandleInstanceLogsReceived;
+		}
+	}
+
+	private void OnConnectionClosed() {
+		lock (this) {
+			agentManager.AgentsChanged.Unsubscribe(this);
+			instanceManager.InstancesChanged.Unsubscribe(this);
+			instanceLogManager.LogsReceived -= HandleInstanceLogsReceived;
+		}
+	}
+
+	private void HandleAgentsChanged(ImmutableArray<Agent> agents) {
+		var message = new RefreshAgentsMessage(agents.Select(static agent => new AgentWithStats(agent.Guid, agent.Name, agent.ProtocolVersion, agent.BuildVersion, agent.MaxInstances, agent.MaxMemory, agent.AllowedServerPorts, agent.AllowedRconPorts, agent.Stats, agent.LastPing, agent.IsOnline)).ToImmutableArray());
+		taskManager.Run("Send agents to web", () => connection.Send(message));
+	}
+
+	private void HandleInstancesChanged(ImmutableDictionary<Guid, Instance> instances) {
+		var message = new RefreshInstancesMessage(instances.Values.ToImmutableArray());
+		taskManager.Run("Send instances to web", () => connection.Send(message));
+	}
+
+	private void HandleInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
+		taskManager.Run("Send instance logs to web", () => connection.Send(new InstanceOutputMessage(e.InstanceGuid, e.Lines)));
+	}
+
+	public async Task<NoReply> HandleRegisterWeb(RegisterWebMessage message) {
+		if (authToken.FixedTimeEquals(message.AuthToken)) {
+			Logger.Information("Web authorized successfully.");
+			connection.IsAuthorized = true;
+			await connection.Send(new RegisterWebResultMessage(true));
+		}
+		else {
+			Logger.Warning("Web failed to authorize, invalid token.");
+			await connection.Send(new RegisterWebResultMessage(false));
+		}
+
+		if (!connection.IsClosed) {
+			OnConnectionReady();
+		}
+
+		return NoReply.Instance;
+	}
+
+	public Task<NoReply> HandleUnregisterWeb(UnregisterWebMessage message) {
+		if (!connection.IsClosed) {
+			connection.Close();
+			OnConnectionClosed();
+		}
+
+		return Task.FromResult(NoReply.Instance);
+	}
+
+	public Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
+		return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
+	}
+
+	public Task<CreateUserResult> HandleCreateUser(CreateUserMessage message) {
+		return userManager.Create(message.LoggedInUserGuid, message.Username, message.Password);
+	}
+
+	public Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) {
+		return userManager.GetAll();
+	}
+
+	public Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message) {
+		return roleManager.GetAll();
+	}
+
+	public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message) {
+		return userRoleManager.GetUserRoles(message.UserGuids);
+	}
+
+	public Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message) {
+		return userRoleManager.ChangeUserRoles(message.LoggedInUserGuid, message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
+	}
+
+	public Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message) {
+		return userManager.DeleteByGuid(message.LoggedInUserGuid, message.SubjectUserGuid);
+	}
+
+	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
+		return instanceManager.CreateOrUpdateInstance(message.LoggedInUserGuid, message.Configuration);
+	}
+
+	public Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {
+		return instanceManager.LaunchInstance(message.LoggedInUserGuid, message.InstanceGuid);
+	}
+
+	public Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) {
+		return instanceManager.StopInstance(message.LoggedInUserGuid, message.InstanceGuid, message.StopStrategy);
+	}
+
+	public Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
+		return instanceManager.SendCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Command);
+	}
+
+	public Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) {
+		return minecraftVersions.GetVersions(CancellationToken.None);
+	}
+
+	public Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) {
+		return Task.FromResult(agentJavaRuntimesManager.All);
+	}
+
+	public Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message) {
+		return auditLogManager.GetMostRecentItems(message.Count);
+	}
+
+	public Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message) {
+		return eventLogManager.GetMostRecentItems(message.Count);
+	}
+
+	public Task<LogInSuccess?> HandleLogIn(LogInMessage message) {
+		return userLoginManager.LogIn(message.Username, message.Password);
 	}
 
 	public Task<NoReply> HandleReply(ReplyMessage message) {
diff --git a/Controller/Phantom.Controller.Services/Users/AddUserError.cs b/Controller/Phantom.Controller.Services/Users/AddUserError.cs
deleted file mode 100644
index a40d6af..0000000
--- a/Controller/Phantom.Controller.Services/Users/AddUserError.cs
+++ /dev/null
@@ -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;
-}
diff --git a/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs b/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs
new file mode 100644
index 0000000..1511802
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs
@@ -0,0 +1,19 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Controller.Database;
+using Phantom.Controller.Database.Repositories;
+
+namespace Phantom.Controller.Services.Users; 
+
+sealed class AuditLogManager {
+	private readonly IDbContextProvider dbProvider;
+
+	public AuditLogManager(IDbContextProvider dbProvider) {
+		this.dbProvider = dbProvider;
+	}
+
+	public async Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count) {
+		await using var db = dbProvider.Lazy();
+		return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs b/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs
deleted file mode 100644
index c29dff3..0000000
--- a/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs
+++ /dev/null
@@ -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;
-}
diff --git a/Controller/Phantom.Controller.Services/Users/PermissionManager.cs b/Controller/Phantom.Controller.Services/Users/PermissionManager.cs
new file mode 100644
index 0000000..6e00a4e
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Users/PermissionManager.cs
@@ -0,0 +1,70 @@
+using System.Collections.Immutable;
+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 Serilog;
+
+namespace Phantom.Controller.Services.Users;
+
+sealed class PermissionManager {
+	private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
+
+	private readonly IDbContextProvider dbProvider;
+
+	public PermissionManager(IDbContextProvider dbProvider) {
+		this.dbProvider = dbProvider;
+	}
+
+	public async Task Initialize() {
+		Logger.Information("Adding default permissions to database.");
+
+		await using var ctx = dbProvider.Eager();
+
+		var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
+		var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
+		if (!missingPermissionIds.IsEmpty) {
+			Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds));
+
+			foreach (var permissionId in missingPermissionIds) {
+				ctx.Permissions.Add(new PermissionEntity(permissionId));
+			}
+
+			await ctx.SaveChangesAsync();
+		}
+	}
+
+	public async Task<PermissionSet> FetchPermissionsForAllUsers(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 PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
+	}
+	
+	public async Task<PermissionSet> 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 PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
+	}
+
+	public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
+		return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs b/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs
deleted file mode 100644
index 9d4ad14..0000000
--- a/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Collections.Immutable;
-
-namespace Phantom.Controller.Services.Users.Permissions;
-
-public sealed class IdentityPermissions {
-	internal static IdentityPermissions None { get; } = new ();
-	
-	private readonly ImmutableHashSet<string> permissionIds;
-
-	internal IdentityPermissions(IQueryable<string> permissionIdsQuery) {
-		this.permissionIds = permissionIdsQuery.ToImmutableHashSet();
-	}
-
-	private IdentityPermissions() {
-		this.permissionIds = ImmutableHashSet<string>.Empty;
-	}
-
-	public bool Check(Permission? permission) {
-		while (permission != null) {
-			if (!permissionIds.Contains(permission.Id)) {
-				return false;
-			}
-
-			permission = permission.Parent;
-		}
-
-		return true;
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs b/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs
deleted file mode 100644
index f8560d0..0000000
--- a/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System.Collections.Immutable;
-using System.Security.Claims;
-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.Permissions;
-
-public sealed class PermissionManager {
-	private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
-	
-	private readonly IDatabaseProvider databaseProvider;
-	private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
-
-	public PermissionManager(IDatabaseProvider databaseProvider) {
-		this.databaseProvider = databaseProvider;
-	}
-
-	internal async Task Initialize() {
-		Logger.Information("Adding default permissions to database.");
-		
-		await using var ctx = databaseProvider.Provide();
-		
-		var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
-		var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
-		if (!missingPermissionIds.IsEmpty) {
-			Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds));
-			
-			foreach (var permissionId in missingPermissionIds) {
-				ctx.Permissions.Add(new PermissionEntity(permissionId));
-			}
-			
-			await ctx.SaveChangesAsync();
-		}
-	}
-	
-	internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
-		return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
-	}
-
-	private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
-		using var ctx = databaseProvider.Provide();
-		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));
-	}
-
-	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);
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Users/Roles/Role.cs b/Controller/Phantom.Controller.Services/Users/Role.cs
similarity index 89%
rename from Controller/Phantom.Controller.Services/Users/Roles/Role.cs
rename to Controller/Phantom.Controller.Services/Users/Role.cs
index eb2bbd9..bd84a73 100644
--- a/Controller/Phantom.Controller.Services/Users/Roles/Role.cs
+++ b/Controller/Phantom.Controller.Services/Users/Role.cs
@@ -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 ();
diff --git a/Controller/Phantom.Controller.Services/Users/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/RoleManager.cs
new file mode 100644
index 0000000..fc09c46
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Users/RoleManager.cs
@@ -0,0 +1,62 @@
+using System.Collections.Immutable;
+using Microsoft.EntityFrameworkCore;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Logging;
+using Phantom.Controller.Database;
+using Phantom.Controller.Database.Entities;
+using Phantom.Controller.Database.Repositories;
+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();
+	}
+
+	public async Task<ImmutableArray<RoleInfo>> GetAll() {
+		await using var db = dbProvider.Lazy();
+		var roleRepository = new RoleRepository(db);
+		
+		var allRoles = await roleRepository.GetAll();
+		return allRoles.Select(static role => role.ToRoleInfo()).ToImmutableArray();
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs
deleted file mode 100644
index 39e5aac..0000000
--- a/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs
+++ /dev/null
@@ -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);
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs
deleted file mode 100644
index 579fab1..0000000
--- a/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs
+++ /dev/null
@@ -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;
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs b/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs
deleted file mode 100644
index afb887b..0000000
--- a/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs
+++ /dev/null
@@ -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;
-}
diff --git a/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs b/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs
new file mode 100644
index 0000000..889949a
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs
@@ -0,0 +1,34 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Controller.Services.Users; 
+
+sealed class UserLoginManager {
+	private const int SessionIdBytes = 20;
+	private readonly ConcurrentDictionary<string, List<ImmutableArray<byte>>> sessionTokensByUsername = new ();
+	
+	private readonly UserManager userManager;
+	private readonly PermissionManager permissionManager;
+	
+	public UserLoginManager(UserManager userManager, PermissionManager permissionManager) {
+		this.userManager = userManager;
+		this.permissionManager = permissionManager;
+	}
+
+	public async Task<LogInSuccess?> LogIn(string username, string password) {
+		var user = await userManager.GetAuthenticated(username, password);
+		if (user == null) {
+			return null;
+		}
+
+		var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
+		var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<ImmutableArray<byte>>());
+		lock (sessionTokens) {
+			sessionTokens.Add(token);
+		}
+		
+		return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token);
+	}
+}
diff --git a/Controller/Phantom.Controller.Services/Users/UserManager.cs b/Controller/Phantom.Controller.Services/Users/UserManager.cs
index 7866933..1bb22f7 100644
--- a/Controller/Phantom.Controller.Services/Users/UserManager.cs
+++ b/Controller/Phantom.Controller.Services/Users/UserManager.cs
@@ -1,136 +1,134 @@
 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 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 async Task<ImmutableArray<UserInfo>> GetAll() {
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
 
-		var claim = user.FindFirst(ClaimTypes.NameIdentifier);
-		if (claim == null) {
-			return null;
-		}
-
-		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);
+		var allUsers = await userRepository.GetAll();
+		return allUsers.Select(static user => user.ToUserInfo()).ToImmutableArray();
 	}
 
 	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;
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
+
+		var user = await userRepository.GetByName(username);
+		return user != null && UserPasswords.Verify(password, user.PasswordHash) ? 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));
-		}
+	public async Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministrator(string username, string password) {
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
+		var auditLogWriter = new AuditLogRepository(db).Writer(currentUserGuid: null);
 
-		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());
-			}
+			bool wasCreated;
 
-			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 userRepository.GetByName(username);
 			if (user == null) {
-				return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
+				var result = await userRepository.CreateUser(username, password);
+				if (result) {
+					user = result.Value;
+					auditLogWriter.AdministratorUserCreated(user);
+					wasCreated = true;
+				}
+				else {
+					return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.CreationFailed(result.Error);
+				}
 			}
-
-			foundUser = user;
-			try {
-				var requirementViolations = UserPasswords.CheckRequirements(password);
-				if (!requirementViolations.IsEmpty) {
-					return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
+			else {
+				var result = userRepository.SetUserPassword(user, password);
+				if (!result) {
+					return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.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());
+				auditLogWriter.AdministratorUserModified(user);
+				wasCreated = false;
 			}
-		}
 
-		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 Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.AddingToRoleFailed();
+			}
+
+			await new UserRoleRepository(db).Add(user, role);
+			await db.Ctx.SaveChangesAsync();
+
+			// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
+			if (wasCreated) {
+				Logger.Information("Created administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
+			}
+			else {
+				Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
+			}
+
+			return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.Success(user.ToUserInfo());
+		} catch (Exception e) {
+			Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username);
+			return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UnknownError();
+		}
 	}
 
-	public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
-		await using var ctx = databaseProvider.Provide();
-		var user = await ctx.Users.FindAsync(guid);
+	public async Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password) {
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
+		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
+
+		try {
+			var result = await userRepository.CreateUser(username, password);
+			if (!result) {
+				return new Common.Data.Web.Users.CreateUserResults.CreationFailed(result.Error);
+			}
+
+			var user = result.Value;
+			
+			auditLogWriter.UserCreated(user);
+			await db.Ctx.SaveChangesAsync();
+
+			Logger.Information("Created user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
+			return new Common.Data.Web.Users.CreateUserResults.Success(user.ToUserInfo());
+		} catch (Exception e) {
+			Logger.Error(e, "Could not create user \"{Username}\".", username);
+			return new Common.Data.Web.Users.CreateUserResults.UnknownError();
+		}
+	}
+	
+	public async Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid) {
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
+
+		var user = await userRepository.GetByGuid(userGuid);
 		if (user == null) {
 			return DeleteUserResult.NotFound;
 		}
 
+		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
 		try {
-			ctx.Users.Remove(user);
-			await ctx.SaveChangesAsync();
+			userRepository.DeleteUser(user);
+			auditLogWriter.UserDeleted(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;
 		}
 	}
diff --git a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs b/Controller/Phantom.Controller.Services/Users/UserPasswords.cs
deleted file mode 100644
index 1618395..0000000
--- a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs
+++ /dev/null
@@ -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);
-	}
-}
diff --git a/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs
new file mode 100644
index 0000000..c29f5ee
--- /dev/null
+++ b/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs
@@ -0,0 +1,72 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Logging;
+using Phantom.Controller.Database;
+using Phantom.Controller.Database.Repositories;
+using Serilog;
+
+namespace Phantom.Controller.Services.Users; 
+
+sealed class UserRoleManager {
+	private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
+	
+	private readonly IDbContextProvider dbProvider;
+	
+	public UserRoleManager(IDbContextProvider dbProvider) {
+		this.dbProvider = dbProvider;
+	}
+
+	public async Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(ImmutableHashSet<Guid> userGuids) {
+		await using var db = dbProvider.Lazy();
+		return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids);
+	}
+
+	public async Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) {
+		await using var db = dbProvider.Lazy();
+		var userRepository = new UserRepository(db);
+		
+		var user = await userRepository.GetByGuid(subjectUserGuid);
+		if (user == null) {
+			return new ChangeUserRolesResult(ImmutableHashSet<Guid>.Empty, ImmutableHashSet<Guid>.Empty);
+		}
+
+		var roleRepository = new RoleRepository(db);
+		var userRoleRepository = new UserRoleRepository(db);
+		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
+		
+		var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids));
+		
+		var addedToRoleGuids = ImmutableHashSet.CreateBuilder<Guid>();
+		var addedToRoleNames = new List<string>();
+		
+		var removedFromRoleGuids = ImmutableHashSet.CreateBuilder<Guid>();
+		var removedFromRoleNames = new List<string>();
+        
+		try {
+			foreach (var roleGuid in addToRoleGuids) {
+				if (rolesByGuid.TryGetValue(roleGuid, out var role)) {
+					await userRoleRepository.Add(user, role);
+					addedToRoleGuids.Add(roleGuid);
+					addedToRoleNames.Add(role.Name);
+				}
+			}
+			
+			foreach (var roleGuid in removeFromRoleGuids) {
+				if (rolesByGuid.TryGetValue(roleGuid, out var role)) {
+					await userRoleRepository.Remove(user, role);
+					removedFromRoleGuids.Add(roleGuid);
+					removedFromRoleNames.Add(role.Name);
+				}
+			}
+
+			auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames);
+			await db.Ctx.SaveChangesAsync();
+			
+			Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
+			return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable());
+		} catch (Exception e) {
+			Logger.Error(e, "Could not change roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
+			return new ChangeUserRolesResult(ImmutableHashSet<Guid>.Empty, ImmutableHashSet<Guid>.Empty);
+		}
+	}
+}
diff --git a/Controller/Phantom.Controller/ConnectionKeyData.cs b/Controller/Phantom.Controller/ConnectionKeyData.cs
index c9d3f3e..124b322 100644
--- a/Controller/Phantom.Controller/ConnectionKeyData.cs
+++ b/Controller/Phantom.Controller/ConnectionKeyData.cs
@@ -1,5 +1,5 @@
 using NetMQ;
-using Phantom.Common.Data.Agent;
+using Phantom.Common.Data;
 
 namespace Phantom.Controller;
 
diff --git a/Controller/Phantom.Controller/ConnectionKeyFiles.cs b/Controller/Phantom.Controller/ConnectionKeyFiles.cs
index d2856c5..1fc3736 100644
--- a/Controller/Phantom.Controller/ConnectionKeyFiles.cs
+++ b/Controller/Phantom.Controller/ConnectionKeyFiles.cs
@@ -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;
diff --git a/Controller/Phantom.Controller/Program.cs b/Controller/Phantom.Controller/Program.cs
index c09f980..78190b4 100644
--- a/Controller/Phantom.Controller/Program.cs
+++ b/Controller/Phantom.Controller/Program.cs
@@ -52,7 +52,7 @@ try {
 	}
 	
 	var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
-	var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, shutdownCancellationToken);
+	var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, webKeyData.AuthToken, shutdownCancellationToken);
 	
 	PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
 	
diff --git a/Packages.props b/Packages.props
index 18f9205..d086419 100644
--- a/Packages.props
+++ b/Packages.props
@@ -6,6 +6,7 @@
     <PackageReference Update="Microsoft.EntityFrameworkCore.Relational"          Version="7.0.11" />
     <PackageReference Update="Microsoft.EntityFrameworkCore.Tools"               Version="7.0.11" />
     <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL"             Version="7.0.11" />
+    <PackageReference Update="System.Linq.Async"                                 Version="4.0.0" />
   </ItemGroup>
   
   <ItemGroup>
diff --git a/PhantomPanel.sln b/PhantomPanel.sln
index 12c0428..52569f2 100644
--- a/PhantomPanel.sln
+++ b/PhantomPanel.sln
@@ -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
diff --git a/README.md b/README.md
index 9cd823e..a0be6c3 100644
--- a/README.md
+++ b/README.md
@@ -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
 
diff --git a/Utils/Phantom.Utils.Events/SimpleObservableState.cs b/Utils/Phantom.Utils.Events/SimpleObservableState.cs
new file mode 100644
index 0000000..dd16f3d
--- /dev/null
+++ b/Utils/Phantom.Utils.Events/SimpleObservableState.cs
@@ -0,0 +1,20 @@
+using Serilog;
+
+namespace Phantom.Utils.Events; 
+
+public sealed class SimpleObservableState<T> : ObservableState<T> {
+	public T Value { get; private set; }
+
+	public SimpleObservableState(ILogger logger, T initialValue) : base(logger) {
+		this.Value = initialValue;
+	}
+
+	public void SetTo(T newValue) {
+		this.Value = newValue;
+		Update();
+	}
+	
+	protected override T GetData() {
+		return Value;
+	}
+}
diff --git a/Utils/Phantom.Utils.Rpc/Message/IReply.cs b/Utils/Phantom.Utils.Rpc/Message/IReply.cs
index de29f5e..34e4b17 100644
--- a/Utils/Phantom.Utils.Rpc/Message/IReply.cs
+++ b/Utils/Phantom.Utils.Rpc/Message/IReply.cs
@@ -1,4 +1,4 @@
-namespace Phantom.Utils.Rpc.Message; 
+namespace Phantom.Utils.Rpc.Message;
 
 public interface IReply {
 	uint SequenceId { get; }
diff --git a/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs b/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs
index 7cc0331..b160af9 100644
--- a/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs
+++ b/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs
@@ -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;
diff --git a/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs b/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs
index be3350a..9407074 100644
--- a/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs
+++ b/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs
@@ -32,4 +32,14 @@ public static class EnumerableExtensions {
 
 		return builder.ToImmutable();
 	}
+	
+	public static async Task<ImmutableDictionary<TKey, TValue>> ToImmutableDictionaryAsync<TSource, TKey, TValue>(this IAsyncEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TValue> valueSelector, CancellationToken cancellationToken = default) where TKey : notnull {
+		var builder = ImmutableDictionary.CreateBuilder<TKey, TValue>();
+		
+		await foreach (var element in source.WithCancellation(cancellationToken)) {
+			builder.Add(keySelector(element), valueSelector(element));
+		}
+
+		return builder.ToImmutable();
+	}
 }
diff --git a/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs b/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs
index 9d798b1..8f66157 100644
--- a/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs
+++ b/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs
@@ -69,6 +69,26 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 		}
 	}
 
+	public bool GetOrAdd(TKey key, Func<TKey, TValue> valueFactory, out TValue value) {
+		rwLock.EnterUpgradeableReadLock();
+		try {
+			if (dict.TryGetValue(key, out var existingValue)) {
+				value = existingValue;
+				return false;
+			}
+
+			rwLock.EnterWriteLock();
+			try {
+				dict[key] = value = valueFactory(key);
+				return true;
+			} finally {
+				rwLock.ExitWriteLock();
+			}
+		} finally {
+			rwLock.ExitUpgradeableReadLock();
+		}
+	}
+	
 	public bool TryAdd(TKey key, TValue newValue) {
 		rwLock.EnterWriteLock();
 		try {
@@ -108,11 +128,11 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 		}
 	}
 
-	public bool TryReplace(TKey key, Func<TValue, TValue> replacementValue) {
-		return TryReplaceIf(key, replacementValue, static _ => true);
+	public bool TryReplace(TKey key, Func<TValue, TValue> replacementValueFactory) {
+		return TryReplaceIf(key, replacementValueFactory, static _ => true);
 	}
 
-	public bool TryReplaceIf(TKey key, Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) {
+	public bool TryReplaceIf(TKey key, Func<TValue, TValue> replacementValueFactory, Predicate<TValue> replaceCondition) {
 		rwLock.EnterUpgradeableReadLock();
 		try {
 			if (!dict.TryGetValue(key, out var oldValue) || !replaceCondition(oldValue)) {
@@ -121,7 +141,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 
 			rwLock.EnterWriteLock();
 			try {
-				dict[key] = replacementValue(oldValue);
+				dict[key] = replacementValueFactory(oldValue);
 				return true;
 			} finally {
 				rwLock.ExitWriteLock();
@@ -131,11 +151,11 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 		}
 	}
 
-	public bool ReplaceAll(Func<TValue, TValue> replacementValue) {
+	public bool ReplaceAll(Func<TValue, TValue> replacementValueFactory) {
 		rwLock.EnterWriteLock();
 		try {
 			foreach (var (key, oldValue) in dict) {
-				dict[key] = replacementValue(oldValue);
+				dict[key] = replacementValueFactory(oldValue);
 			}
 
 			return dict.Count > 0;
@@ -144,7 +164,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 		}
 	}
 
-	public bool ReplaceAllIf(Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) {
+	public bool ReplaceAllIf(Func<TValue, TValue> replacementValueFactory, Predicate<TValue> replaceCondition) {
 		rwLock.EnterUpgradeableReadLock();
 		try {
 			bool hasChanged = false;
@@ -157,7 +177,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
 						}
 
 						hasChanged = true;
-						dict[key] = replacementValue(oldValue);
+						dict[key] = replacementValueFactory(oldValue);
 					}
 				}
 			} finally {
diff --git a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs b/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs
index 8b0ddcd..ecbfce4 100644
--- a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs
+++ b/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs
@@ -47,6 +47,10 @@ public sealed class RwLockedObservableDictionary<TKey, TValue> where TKey : notn
 	public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) {
 		return dict.TryGetValue(key, out value);
 	}
+	
+	public bool GetOrAdd(TKey key, Func<TKey, TValue> valueFactory, out TValue value) {
+		return FireCollectionChangedIf(dict.GetOrAdd(key, valueFactory, out value));
+	}
 
 	public bool TryAdd(TKey key, TValue newValue) {
 		return FireCollectionChangedIf(dict.TryAdd(key, newValue));
diff --git a/Utils/Phantom.Utils/Tasks/Result.cs b/Utils/Phantom.Utils/Tasks/Result.cs
index a925efe..eba7082 100644
--- a/Utils/Phantom.Utils/Tasks/Result.cs
+++ b/Utils/Phantom.Utils/Tasks/Result.cs
@@ -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>;
diff --git a/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs b/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs
deleted file mode 100644
index eb3b500..0000000
--- a/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System.Security.Claims;
-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;
-
-public sealed class PhantomLoginManager {
-	private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>();
-
-	public static bool IsAuthenticated(ClaimsPrincipal user) {
-		return user.Identity is { IsAuthenticated: true };
-	}
-
-	private readonly INavigation navigation;
-	private readonly UserManager userManager;
-	private readonly PhantomLoginStore loginStore;
-	private readonly ILoginEvents loginEvents;
-
-	public PhantomLoginManager(INavigation navigation, UserManager userManager, PhantomLoginStore loginStore, ILoginEvents loginEvents) {
-		this.navigation = navigation;
-		this.userManager = userManager;
-		this.loginStore = loginStore;
-		this.loginEvents = loginEvents;
-	}
-
-	public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
-		if (await userManager.GetAuthenticated(username, password) == null) {
-			return false;
-		}
-		
-		Logger.Debug("Created login token for {Username}.", username);
-
-		string token = TokenGenerator.Create(60);
-		loginStore.Add(token, username, password, returnUrl ?? string.Empty);
-		navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true);
-
-		return true;
-	}
-
-	internal async Task<SignInResult?> ProcessToken(string token) {
-		var entry = loginStore.Pop(token);
-		if (entry == null) {
-			return null;
-		}
-
-		var user = await userManager.GetAuthenticated(entry.Username, entry.Password);
-		if (user == null) {
-			return null;
-		}
-
-		Logger.Information("Successful login for {Username}.", user.Name);
-		loginEvents.UserLoggedIn(user);
-
-		var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
-		identity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
-		identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserGuid.ToString()));
-
-		var authenticationProperties = new AuthenticationProperties {
-			IsPersistent = true
-		};
-		
-		return new SignInResult(new ClaimsPrincipal(identity), authenticationProperties, entry.ReturnUrl);
-	}
-
-	internal sealed record SignInResult(ClaimsPrincipal ClaimsPrincipal, AuthenticationProperties AuthenticationProperties, string ReturnUrl);
-
-	internal void OnSignedOut(ClaimsPrincipal user) {
-		if (UserManager.GetAuthenticatedUserId(user) is {} userGuid) {
-			loginEvents.UserLoggedOut(userGuid);
-		}
-	}
-}
diff --git a/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs b/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs
deleted file mode 100644
index 9e7d0d1..0000000
--- a/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using Phantom.Common.Logging;
-using Phantom.Utils.Tasks;
-using ILogger = Serilog.ILogger;
-
-namespace Phantom.Web.Identity.Authentication;
-
-public sealed class PhantomLoginStore {
-	private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>();
-	private static readonly TimeSpan ExpirationTime = TimeSpan.FromMinutes(1);
-
-	internal static Func<IServiceProvider, PhantomLoginStore> Create(CancellationToken cancellationToken) {
-		return provider => new PhantomLoginStore(provider.GetRequiredService<TaskManager>(), cancellationToken);
-	}
-
-	private readonly ConcurrentDictionary<string, LoginEntry> loginEntries = new ();
-	private readonly CancellationToken cancellationToken;
-
-	private PhantomLoginStore(TaskManager taskManager, CancellationToken cancellationToken) {
-		this.cancellationToken = cancellationToken;
-		taskManager.Run("Web login entry expiration loop", RunExpirationLoop);
-	}
-	
-	private async Task RunExpirationLoop() {
-		try {
-			while (true) {
-				await Task.Delay(ExpirationTime, cancellationToken);
-
-				foreach (var (token, entry) in loginEntries) {
-					if (entry.IsExpired) {
-						Logger.Debug("Expired login entry for {Username}.", entry.Username);
-						loginEntries.TryRemove(token, out _);
-					}
-				}
-			}
-		} finally {
-			Logger.Information("Expiration loop stopped.");
-		}
-	}
-
-	internal void Add(string token, string username, string password, string returnUrl) {
-		loginEntries[token] = new LoginEntry(username, password, returnUrl, Stopwatch.StartNew());
-	}
-
-	internal LoginEntry? Pop(string token) {
-		if (!loginEntries.TryRemove(token, out var entry)) {
-			return null;
-		}
-
-		if (entry.IsExpired) {
-			Logger.Debug("Expired login entry for {Username}.", entry.Username);
-			return null;
-		}
-		
-		return entry;
-	}
-
-	internal sealed record LoginEntry(string Username, string Password, string ReturnUrl, Stopwatch AddedTime) {
-		public bool IsExpired => AddedTime.Elapsed >= ExpirationTime;
-	}
-}
diff --git a/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs b/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs
deleted file mode 100644
index 83c626b..0000000
--- a/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs
+++ /dev/null
@@ -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);
-}
diff --git a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj b/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj
deleted file mode 100644
index 32fc9d8..0000000
--- a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj
+++ /dev/null
@@ -1,26 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
-  
-  <PropertyGroup>
-    <ImplicitUsings>enable</ImplicitUsings>
-    <Nullable>enable</Nullable>
-  </PropertyGroup>
-  
-  <PropertyGroup>
-    <OutputType>Library</OutputType>
-  </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.Logging\Phantom.Common.Logging.csproj" />
-    <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
-  </ItemGroup>
-
-</Project>
diff --git a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs b/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs
deleted file mode 100644
index 8159bd9..0000000
--- a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Components.Authorization;
-using Microsoft.AspNetCore.Components.Server;
-using Phantom.Web.Identity.Authentication;
-using Phantom.Web.Identity.Authorization;
-
-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);
-
-		services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
-		services.AddScoped<PhantomLoginManager>();
-		
-		services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
-		services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
-	}
-
-	public static void UsePhantomIdentity(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)));
-		}
-	}
-}
diff --git a/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs b/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs
deleted file mode 100644
index f24ce11..0000000
--- a/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using Microsoft.AspNetCore.Authentication;
-using Phantom.Web.Identity.Authentication;
-using Phantom.Web.Identity.Interfaces;
-
-namespace Phantom.Web.Identity;
-
-sealed class PhantomIdentityMiddleware {
-	public const string LoginPath = "/login";
-	public const string LogoutPath = "/logout";
-
-	public static bool AcceptsPath(HttpContext context) {
-		var path = context.Request.Path;
-		return path == LoginPath || path == LogoutPath;
-	}
-	
-	private readonly RequestDelegate next;
-
-	public PhantomIdentityMiddleware(RequestDelegate next) {
-		this.next = next;
-	}
-
-	[SuppressMessage("ReSharper", "UnusedMember.Global")]
-	public async Task InvokeAsync(HttpContext context, INavigation navigation, PhantomLoginManager loginManager) {
-		var path = context.Request.Path;
-		if (path == LoginPath && context.Request.Query.TryGetValue("token", out var tokens) && tokens[0] is {} token && await loginManager.ProcessToken(token) is {} result) {
-			await context.SignInAsync(result.ClaimsPrincipal, result.AuthenticationProperties);
-			context.Response.Redirect(navigation.BasePath + result.ReturnUrl);
-		}
-		else if (path == LogoutPath) {
-			loginManager.OnSignedOut(context.User);
-			await context.SignOutAsync();
-			context.Response.Redirect(navigation.BasePath);
-		}
-		else {
-			await next.Invoke(context);
-		}
-	}
-}
diff --git a/Web/Phantom.Web.Services/Agents/AgentManager.cs b/Web/Phantom.Web.Services/Agents/AgentManager.cs
new file mode 100644
index 0000000..5d4955d
--- /dev/null
+++ b/Web/Phantom.Web.Services/Agents/AgentManager.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.Agent;
+using Phantom.Common.Logging;
+using Phantom.Utils.Events;
+
+namespace Phantom.Web.Services.Agents; 
+
+public sealed class AgentManager {
+	private readonly SimpleObservableState<ImmutableArray<AgentWithStats>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<AgentWithStats>.Empty);
+
+	public EventSubscribers<ImmutableArray<AgentWithStats>> AgentsChanged => agents.Subs;
+
+	internal void RefreshAgents(ImmutableArray<AgentWithStats> newAgents) {
+		agents.SetTo(newAgents);
+	}
+
+	public ImmutableArray<AgentWithStats> GetAll() {
+		return agents.Value;
+	}
+	
+	public ImmutableDictionary<Guid, AgentWithStats> ToDictionaryByGuid() {
+		return agents.Value.ToImmutableDictionary(static agent => agent.Guid);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs b/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs
new file mode 100644
index 0000000..a53df81
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs
@@ -0,0 +1,40 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Components.Server;
+
+namespace Phantom.Web.Services.Authentication;
+
+public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider {
+	private readonly UserSessionManager sessionManager;
+	private readonly UserSessionBrowserStorage sessionBrowserStorage;
+	private bool isLoaded;
+
+	public CustomAuthenticationStateProvider(UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage) {
+		this.sessionManager = sessionManager;
+		this.sessionBrowserStorage = sessionBrowserStorage;
+	}
+
+	public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
+		if (!isLoaded) {
+			var stored = await sessionBrowserStorage.Get();
+			if (stored != null) {
+				var session = sessionManager.FindWithToken(stored.UserGuid, stored.Token);
+				if (session != null) {
+					SetLoadedSession(session);
+				}
+			}
+		}
+
+		return await base.GetAuthenticationStateAsync();
+	}
+
+	internal void SetLoadedSession(UserInfo user) {
+		isLoaded = true;
+		SetAuthenticationState(Task.FromResult(new AuthenticationState(user.AsClaimsPrincipal)));
+	}
+
+	internal void SetUnloadedSession() {
+		isLoaded = false;
+		SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/UserInfo.cs b/Web/Phantom.Web.Services/Authentication/UserInfo.cs
new file mode 100644
index 0000000..ceb441d
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/UserInfo.cs
@@ -0,0 +1,23 @@
+using System.Security.Claims;
+using Phantom.Common.Data.Web.Users;
+
+namespace Phantom.Web.Services.Authentication;
+
+public sealed record UserInfo(Guid UserGuid, string Username, PermissionSet Permissions) {
+	private const string AuthenticationType = "Phantom";
+
+	internal ClaimsPrincipal AsClaimsPrincipal {
+		get {
+			var identity = new ClaimsIdentity(AuthenticationType);
+
+			identity.AddClaim(new Claim(ClaimTypes.Name, Username));
+			identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, UserGuid.ToString()));
+
+			return new ClaimsPrincipal(identity);
+		}
+	}
+
+	public static Guid? TryGetGuid(ClaimsPrincipal principal) {
+		return principal.Identity is { IsAuthenticated: true, AuthenticationType: AuthenticationType } && principal.FindFirstValue(ClaimTypes.NameIdentifier) is {} guidStr && Guid.TryParse(guidStr, out var guid) ? guid : null;
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs b/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs
new file mode 100644
index 0000000..52b59fa
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs
@@ -0,0 +1,63 @@
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Logging;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+using ILogger = Serilog.ILogger;
+
+namespace Phantom.Web.Services.Authentication;
+
+public sealed class UserLoginManager {
+	private static readonly ILogger Logger = PhantomLogger.Create<UserLoginManager>();
+
+	private readonly INavigation navigation;
+	private readonly UserSessionManager sessionManager;
+	private readonly UserSessionBrowserStorage sessionBrowserStorage;
+	private readonly CustomAuthenticationStateProvider authenticationStateProvider;
+	private readonly ControllerConnection controllerConnection;
+
+	public UserLoginManager(INavigation navigation, UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage, CustomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) {
+		this.navigation = navigation;
+		this.sessionManager = sessionManager;
+		this.sessionBrowserStorage = sessionBrowserStorage;
+		this.authenticationStateProvider = authenticationStateProvider;
+		this.controllerConnection = controllerConnection;
+	}
+
+	public async Task<bool> LogIn(string username, string password, string? returnUrl = null) {
+		LogInSuccess? success;
+		try {
+			success = await controllerConnection.Send<LogInMessage, LogInSuccess?>(new LogInMessage(username, password), TimeSpan.FromSeconds(30));
+		} catch (Exception e) {
+			Logger.Error(e, "Could not log in {Username}.", username);
+			return false;
+		}
+
+		if (success == null) {
+			return false;
+		}
+
+		Logger.Information("Successfully logged in {Username}.", username);
+
+		var userGuid = success.UserGuid;
+		var userInfo = new UserInfo(userGuid, username, success.Permissions);
+		var token = success.Token;
+
+		await sessionBrowserStorage.Store(userGuid, token);
+		sessionManager.Add(userInfo, token);
+		
+		authenticationStateProvider.SetLoadedSession(userInfo);
+		await navigation.NavigateTo(returnUrl ?? string.Empty);
+		
+		return true;
+	}
+
+	public async Task LogOut() {
+		var stored = await sessionBrowserStorage.Delete();
+		if (stored != null) {
+			sessionManager.Remove(stored.UserGuid, stored.Token);
+		}
+
+		await navigation.NavigateTo(string.Empty);
+		authenticationStateProvider.SetUnloadedSession();
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs b/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs
new file mode 100644
index 0000000..e67e6e1
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs
@@ -0,0 +1,48 @@
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
+using Phantom.Common.Logging;
+using ILogger = Serilog.ILogger;
+
+namespace Phantom.Web.Services.Authentication; 
+
+public sealed class UserSessionBrowserStorage {
+	private static readonly ILogger Logger = PhantomLogger.Create<UserSessionBrowserStorage>();
+	
+	private const string SessionTokenKey = "PhantomSession";
+
+	private readonly ProtectedLocalStorage localStorage;
+	
+	public UserSessionBrowserStorage(ProtectedLocalStorage localStorage) {
+		this.localStorage = localStorage;
+	}
+	
+	internal sealed record LocalStorageEntry(Guid UserGuid, ImmutableArray<byte> Token);
+
+	internal async Task<LocalStorageEntry?> Get() {
+		try {
+			var result = await localStorage.GetAsync<LocalStorageEntry>(SessionTokenKey);
+			return result.Success ? result.Value : null;
+		} catch (InvalidOperationException) {
+			return null;
+		} catch (CryptographicException) {
+			return null;
+		} catch (Exception e) {
+			Logger.Error(e, "Could not read local storage entry.");
+			return null;
+		}
+	}
+
+	internal async Task Store(Guid userGuid, ImmutableArray<byte> token) {
+		await localStorage.SetAsync(SessionTokenKey, new LocalStorageEntry(userGuid, token));
+	}
+
+	internal async Task<LocalStorageEntry?> Delete() {
+		var oldEntry = await Get();
+		if (oldEntry != null) {
+			await localStorage.DeleteAsync(SessionTokenKey);
+		}
+		
+		return oldEntry;
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs b/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs
new file mode 100644
index 0000000..3e5e424
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs
@@ -0,0 +1,31 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+
+namespace Phantom.Web.Services.Authentication;
+
+public sealed class UserSessionManager {
+	private readonly ConcurrentDictionary<Guid, UserSessions> userSessions = new ();
+
+	internal void Add(UserInfo user, ImmutableArray<byte> token) {
+		userSessions.AddOrUpdate(
+			user.UserGuid,
+			static (_, u) => new UserSessions(u),
+			static (_, sessions, u) => sessions.WithUserInfo(u),
+			user
+		).AddToken(token);
+	}
+
+	internal UserInfo? Find(Guid userGuid) {
+		return userSessions.TryGetValue(userGuid, out var sessions) ? sessions.UserInfo : null;
+	}
+	
+	internal UserInfo? FindWithToken(Guid userGuid, ImmutableArray<byte> token) {
+		return userSessions.TryGetValue(userGuid, out var sessions) && sessions.HasToken(token) ? sessions.UserInfo : null;
+	}
+
+	internal void Remove(Guid userGuid, ImmutableArray<byte> token) {
+		if (userSessions.TryGetValue(userGuid, out var sessions)) {
+			sessions.RemoveToken(token);
+		}
+	}
+}
diff --git a/Web/Phantom.Web.Services/Authentication/UserSessions.cs b/Web/Phantom.Web.Services/Authentication/UserSessions.cs
new file mode 100644
index 0000000..64a82a8
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authentication/UserSessions.cs
@@ -0,0 +1,54 @@
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+
+namespace Phantom.Web.Services.Authentication; 
+
+sealed class UserSessions {
+	public UserInfo UserInfo { get; }
+	
+	private readonly List<ImmutableArray<byte>> tokens = new ();
+	
+	public UserSessions(UserInfo userInfo) {
+		UserInfo = userInfo;
+	}
+
+	private UserSessions(UserInfo userInfo, List<ImmutableArray<byte>> tokens) : this(userInfo) {
+		this.tokens.AddRange(tokens);
+	}
+
+	public UserSessions WithUserInfo(UserInfo user) {
+		List<ImmutableArray<byte>> tokensCopy;
+		lock (tokens) {
+			tokensCopy = new List<ImmutableArray<byte>>(tokens);
+		}
+		
+		return new UserSessions(user, tokensCopy);
+	}
+
+	public void AddToken(ImmutableArray<byte> token) {
+		lock (tokens) {
+			if (!HasToken(token)) {
+				tokens.Add(token);
+			}
+		}
+	}
+
+	public bool HasToken(ImmutableArray<byte> token) {
+		return FindTokenIndex(token) != -1;
+	}
+
+	private int FindTokenIndex(ImmutableArray<byte> token) {
+		lock (tokens) {
+			return tokens.FindIndex(t => CryptographicOperations.FixedTimeEquals(t.AsSpan(), token.AsSpan()));
+		}
+	}
+
+	public void RemoveToken(ImmutableArray<byte> token) {
+		lock (tokens) {
+			int index = FindTokenIndex(token);
+			if (index != -1) {
+				tokens.RemoveAt(index);
+			}
+		}
+	}
+}
diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs
similarity index 94%
rename from Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs
rename to Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs
index 7ec7167..2e7e976 100644
--- a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs
+++ b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs
@@ -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;
diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs
similarity index 63%
rename from Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs
rename to Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs
index 05855a1..0eec338 100644
--- a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs
+++ b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs
@@ -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;
diff --git a/Web/Phantom.Web.Services/Authorization/PermissionManager.cs b/Web/Phantom.Web.Services/Authorization/PermissionManager.cs
new file mode 100644
index 0000000..5d5d953
--- /dev/null
+++ b/Web/Phantom.Web.Services/Authorization/PermissionManager.cs
@@ -0,0 +1,22 @@
+using System.Security.Claims;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Web.Services.Authentication;
+using UserInfo = Phantom.Web.Services.Authentication.UserInfo;
+
+namespace Phantom.Web.Services.Authorization;
+
+public sealed class PermissionManager {
+	private readonly UserSessionManager sessionManager;
+	
+	public PermissionManager(UserSessionManager sessionManager) {
+		this.sessionManager = sessionManager;
+	}
+
+	public PermissionSet GetPermissions(ClaimsPrincipal user) {
+		return UserInfo.TryGetGuid(user) is {} guid && sessionManager.Find(guid) is {} info ? info.Permissions : PermissionSet.None;
+	}
+	
+	public bool CheckPermission(ClaimsPrincipal user, Permission permission) {
+		return GetPermissions(user).Check(permission);
+	}
+}
diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionView.razor b/Web/Phantom.Web.Services/Authorization/PermissionView.razor
similarity index 92%
rename from Web/Phantom.Web.Identity/Authorization/PermissionView.razor
rename to Web/Phantom.Web.Services/Authorization/PermissionView.razor
index b4542e2..23638f6 100644
--- a/Web/Phantom.Web.Identity/Authorization/PermissionView.razor
+++ b/Web/Phantom.Web.Services/Authorization/PermissionView.razor
@@ -1,5 +1,5 @@
 @using Microsoft.AspNetCore.Components.Authorization
-@using Phantom.Web.Identity.Data
+@using Phantom.Common.Data.Web.Users
 @inject PermissionManager PermissionManager
 
 <AuthorizeView>
diff --git a/Web/Phantom.Web.Services/Events/EventLogManager.cs b/Web/Phantom.Web.Services/Events/EventLogManager.cs
new file mode 100644
index 0000000..588b2dc
--- /dev/null
+++ b/Web/Phantom.Web.Services/Events/EventLogManager.cs
@@ -0,0 +1,19 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.EventLog;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Events; 
+
+public sealed class EventLogManager {
+	private readonly ControllerConnection controllerConnection;
+	
+	public EventLogManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
+		var message = new GetEventLogMessage(count);
+		return controllerConnection.Send<GetEventLogMessage, ImmutableArray<EventLogItem>>(message, cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Identity/Interfaces/INavigation.cs b/Web/Phantom.Web.Services/INavigation.cs
similarity index 65%
rename from Web/Phantom.Web.Identity/Interfaces/INavigation.cs
rename to Web/Phantom.Web.Services/INavigation.cs
index d509827..f8aabee 100644
--- a/Web/Phantom.Web.Identity/Interfaces/INavigation.cs
+++ b/Web/Phantom.Web.Services/INavigation.cs
@@ -1,9 +1,9 @@
 using System.Diagnostics.CodeAnalysis;
 
-namespace Phantom.Web.Identity.Interfaces;
+namespace Phantom.Web.Services;
 
 public interface INavigation {
 	string BasePath { get; }
 	bool GetQueryParameter(string key, [MaybeNullWhen(false)] out string value);
-	void NavigateTo(string url, bool forceLoad = false);
+	Task NavigateTo(string url, bool forceLoad = false);
 }
diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs b/Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs
similarity index 95%
rename from Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs
rename to Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs
index dde2592..8384d2c 100644
--- a/Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs
+++ b/Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs
@@ -1,7 +1,7 @@
 using System.Net;
 using System.Text.RegularExpressions;
 
-namespace Phantom.Controller.Services.Instances;
+namespace Phantom.Web.Services.Instances;
 
 static partial class InstanceLogHtmlFilters {
 	/// <summary>
diff --git a/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs b/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs
new file mode 100644
index 0000000..7cc800e
--- /dev/null
+++ b/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs
@@ -0,0 +1,44 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using Phantom.Common.Logging;
+using Phantom.Utils.Collections;
+using Phantom.Utils.Events;
+using ILogger = Serilog.ILogger;
+
+namespace Phantom.Web.Services.Instances; 
+
+public sealed class InstanceLogManager {
+	private const int RetainedLines = 1000;
+	
+	private readonly ConcurrentDictionary<Guid, ObservableInstanceLogs> logsByInstanceGuid = new ();
+	
+	private ObservableInstanceLogs GetInstanceLogs(Guid instanceGuid) {
+		return logsByInstanceGuid.GetOrAdd(instanceGuid, static _ => new ObservableInstanceLogs(PhantomLogger.Create<InstanceLogManager, ObservableInstanceLogs>()));
+	}
+
+	internal void AddLines(Guid instanceGuid, ImmutableArray<string> lines) {
+		GetInstanceLogs(instanceGuid).Add(lines);
+	}
+	
+	public EventSubscribers<RingBuffer<string>> GetSubs(Guid instanceGuid) {
+		return GetInstanceLogs(instanceGuid).Subs;
+	}
+	
+	private sealed class ObservableInstanceLogs : ObservableState<RingBuffer<string>> {
+		private readonly RingBuffer<string> log = new (RetainedLines);
+
+		public ObservableInstanceLogs(ILogger logger) : base(logger) {}
+
+		public void Add(ImmutableArray<string> lines) {
+			foreach (var line in lines) {
+				log.Add(InstanceLogHtmlFilters.Process(line));
+			}
+
+			Update();
+		}
+
+		protected override RingBuffer<string> GetData() {
+			return log;
+		}
+	}
+}
diff --git a/Web/Phantom.Web.Services/Instances/InstanceManager.cs b/Web/Phantom.Web.Services/Instances/InstanceManager.cs
new file mode 100644
index 0000000..0891dc9
--- /dev/null
+++ b/Web/Phantom.Web.Services/Instances/InstanceManager.cs
@@ -0,0 +1,56 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Instance;
+using Phantom.Common.Data.Minecraft;
+using Phantom.Common.Data.Replies;
+using Phantom.Common.Data.Web.Instance;
+using Phantom.Common.Logging;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Utils.Events;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Instances;
+
+using InstanceDictionary = ImmutableDictionary<Guid, Instance>;
+
+public sealed class InstanceManager {
+	private readonly ControllerConnection controllerConnection;
+	private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty);
+	
+	public InstanceManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs;
+	
+	internal void RefreshInstances(ImmutableArray<Instance> newInstances) {
+		instances.SetTo(newInstances.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid));
+	}
+
+	public InstanceDictionary GetAll() {
+		return instances.Value;
+	}
+
+	public Instance? GetByGuid(Guid instanceGuid) {
+		return instances.Value.GetValueOrDefault(instanceGuid);
+	}
+
+	public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid loggedInUserGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) {
+		var message = new CreateOrUpdateInstanceMessage(loggedInUserGuid, configuration);
+		return controllerConnection.Send<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(message, cancellationToken);
+	}
+
+	public Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid loggedInUserGuid, Guid instanceGuid, CancellationToken cancellationToken) {
+		var message = new LaunchInstanceMessage(loggedInUserGuid, instanceGuid);
+		return controllerConnection.Send<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(message, cancellationToken);
+	}
+
+	public Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid loggedInUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
+		var message = new StopInstanceMessage(loggedInUserGuid, instanceGuid, stopStrategy);
+		return controllerConnection.Send<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(message, cancellationToken);
+	}
+
+	public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(Guid loggedInUserGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) {
+		var message = new SendCommandToInstanceMessage(loggedInUserGuid, instanceGuid, command);
+		return controllerConnection.Send<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(message, cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Phantom.Web.Services.csproj b/Web/Phantom.Web.Services/Phantom.Web.Services.csproj
new file mode 100644
index 0000000..982f919
--- /dev/null
+++ b/Web/Phantom.Web.Services/Phantom.Web.Services.csproj
@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+  
+  <PropertyGroup>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+  
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+  </PropertyGroup>
+  
+  <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="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" />
+    <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
+  </ItemGroup>
+  
+  <ItemGroup>
+    <AdditionalFiles Include="Authorization\PermissionView.razor" />
+  </ItemGroup>
+
+</Project>
diff --git a/Web/Phantom.Web.Services/PhantomWebServices.cs b/Web/Phantom.Web.Services/PhantomWebServices.cs
new file mode 100644
index 0000000..ae0ec84
--- /dev/null
+++ b/Web/Phantom.Web.Services/PhantomWebServices.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Web.Services.Agents;
+using Phantom.Web.Services.Authentication;
+using Phantom.Web.Services.Authorization;
+using Phantom.Web.Services.Events;
+using Phantom.Web.Services.Instances;
+using Phantom.Web.Services.Rpc;
+using Phantom.Web.Services.Users;
+
+namespace Phantom.Web.Services;
+
+public static class PhantomWebServices {
+	public static void AddPhantomServices(this IServiceCollection services) {
+		services.AddSingleton<ControllerConnection>();
+		services.AddSingleton<MessageListener>();
+		
+		services.AddSingleton<AgentManager>();
+		services.AddSingleton<InstanceManager>();
+		services.AddSingleton<InstanceLogManager>();
+		services.AddSingleton<EventLogManager>();
+		
+		services.AddSingleton<UserManager>();
+		services.AddSingleton<UserSessionManager>();
+		services.AddSingleton<AuditLogManager>();
+		services.AddScoped<UserLoginManager>();
+		services.AddScoped<UserSessionBrowserStorage>();
+		
+		services.AddSingleton<RoleManager>();
+		services.AddSingleton<UserRoleManager>();
+		
+		services.AddScoped<CustomAuthenticationStateProvider>();
+		services.AddScoped<AuthenticationStateProvider>(static services => services.GetRequiredService<CustomAuthenticationStateProvider>());
+		
+		services.AddAuthorization(ConfigureAuthorization);
+		services.AddSingleton<PermissionManager>();
+		services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
+	}
+	
+	public static void UsePhantomServices(this IApplicationBuilder application) {
+		application.UseAuthorization();
+	}
+
+	private static void ConfigureAuthorization(AuthorizationOptions o) {
+		foreach (var permission in Permission.All) {
+			o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission)));
+		}
+	}
+}
diff --git a/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs b/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs
new file mode 100644
index 0000000..a2e3d6f
--- /dev/null
+++ b/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs
@@ -0,0 +1,24 @@
+using Phantom.Common.Messages.Web;
+using Phantom.Utils.Rpc;
+
+namespace Phantom.Web.Services.Rpc;
+
+public sealed class ControllerConnection {
+	private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
+	
+	public ControllerConnection(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 waitForReplyTime, CancellationToken waitForReplyCancellationToken = default) where TMessage : IMessageToController<TReply> {
+		return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
+	}
+	
+	public Task<TReply> Send<TMessage, TReply>(TMessage message, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> {
+		return connection.Send<TMessage, TReply>(message, Timeout.InfiniteTimeSpan, waitForReplyCancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Rpc/MessageListener.cs b/Web/Phantom.Web.Services/Rpc/MessageListener.cs
new file mode 100644
index 0000000..6b821f4
--- /dev/null
+++ b/Web/Phantom.Web.Services/Rpc/MessageListener.cs
@@ -0,0 +1,51 @@
+using Phantom.Common.Messages.Web;
+using Phantom.Common.Messages.Web.BiDirectional;
+using Phantom.Common.Messages.Web.ToWeb;
+using Phantom.Utils.Rpc;
+using Phantom.Utils.Rpc.Message;
+using Phantom.Utils.Tasks;
+using Phantom.Web.Services.Agents;
+using Phantom.Web.Services.Instances;
+
+namespace Phantom.Web.Services.Rpc; 
+
+public sealed class MessageListener : IMessageToWebListener {
+	public TaskCompletionSource<bool> RegisterSuccessWaiter { get; } = AsyncTasks.CreateCompletionSource<bool>();
+
+	private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
+	private readonly AgentManager agentManager;
+	private readonly InstanceManager instanceManager;
+	private readonly InstanceLogManager instanceLogManager;
+	
+	public MessageListener(RpcConnectionToServer<IMessageToControllerListener> connection, AgentManager agentManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager) {
+		this.connection = connection;
+		this.agentManager = agentManager;
+		this.instanceManager = instanceManager;
+		this.instanceLogManager = instanceLogManager;
+	}
+
+	public Task<NoReply> HandleRegisterWebResult(RegisterWebResultMessage message) {
+		RegisterSuccessWaiter.TrySetResult(message.Success);
+		return Task.FromResult(NoReply.Instance);
+	}
+
+	public Task<NoReply> HandleRefreshAgents(RefreshAgentsMessage message) {
+		agentManager.RefreshAgents(message.Agents);
+		return Task.FromResult(NoReply.Instance);
+	}
+
+	public Task<NoReply> HandleRefreshInstances(RefreshInstancesMessage message) {
+		instanceManager.RefreshInstances(message.Instances);
+		return Task.FromResult(NoReply.Instance);
+	}
+
+	public Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message) {
+		instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
+		return Task.FromResult(NoReply.Instance);
+	}
+
+	public Task<NoReply> HandleReply(ReplyMessage message) {
+		connection.Receive(message);
+		return Task.FromResult(NoReply.Instance);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs b/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs
new file mode 100644
index 0000000..393265f
--- /dev/null
+++ b/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs
@@ -0,0 +1,27 @@
+using NetMQ;
+using NetMQ.Sockets;
+using Phantom.Common.Messages.Web;
+using Phantom.Common.Messages.Web.BiDirectional;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Utils.Rpc;
+using Phantom.Utils.Rpc.Sockets;
+using ILogger = Serilog.ILogger;
+
+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) {}
+	
+	protected override async Task Disconnect(ClientSocket socket, ILogger logger) {
+		var unregisterMessageBytes = WebMessageRegistries.ToController.Write(new UnregisterWebMessage()).ToArray();
+		try {
+			await socket.SendAsync(unregisterMessageBytes).AsTask().WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
+		} catch (TimeoutException) {
+			logger.Error("Timed out communicating web shutdown with the controller.");
+		}
+	}
+}
diff --git a/Web/Phantom.Web.Services/Users/AuditLogManager.cs b/Web/Phantom.Web.Services/Users/AuditLogManager.cs
new file mode 100644
index 0000000..9f521e7
--- /dev/null
+++ b/Web/Phantom.Web.Services/Users/AuditLogManager.cs
@@ -0,0 +1,19 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.AuditLog;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Users; 
+
+public sealed class AuditLogManager {
+	private readonly ControllerConnection controllerConnection;
+	
+	public AuditLogManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
+		var message = new GetAuditLogMessage(count);
+		return controllerConnection.Send<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(message, cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Users/RoleManager.cs b/Web/Phantom.Web.Services/Users/RoleManager.cs
new file mode 100644
index 0000000..d54a724
--- /dev/null
+++ b/Web/Phantom.Web.Services/Users/RoleManager.cs
@@ -0,0 +1,18 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Users;
+
+public sealed class RoleManager {
+	private readonly ControllerConnection controllerConnection;
+	
+	public RoleManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public Task<ImmutableArray<RoleInfo>> GetAll(CancellationToken cancellationToken) {
+		return controllerConnection.Send<GetRolesMessage, ImmutableArray<RoleInfo>>(new GetRolesMessage(), cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Users/UserManager.cs b/Web/Phantom.Web.Services/Users/UserManager.cs
new file mode 100644
index 0000000..eb0502a
--- /dev/null
+++ b/Web/Phantom.Web.Services/Users/UserManager.cs
@@ -0,0 +1,26 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Users;
+
+public sealed class UserManager {
+	private readonly ControllerConnection controllerConnection;
+	
+	public UserManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public Task<ImmutableArray<UserInfo>> GetAll(CancellationToken cancellationToken) {
+		return controllerConnection.Send<GetUsersMessage, ImmutableArray<UserInfo>>(new GetUsersMessage(), cancellationToken);
+	}
+
+	public Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password, CancellationToken cancellationToken) {
+		return controllerConnection.Send<CreateUserMessage, CreateUserResult>(new CreateUserMessage(loggedInUserGuid, username, password), cancellationToken);
+	}
+	
+	public Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid, CancellationToken cancellationToken) {
+		return controllerConnection.Send<DeleteUserMessage, DeleteUserResult>(new DeleteUserMessage(loggedInUserGuid, userGuid), cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web.Services/Users/UserRoleManager.cs b/Web/Phantom.Web.Services/Users/UserRoleManager.cs
new file mode 100644
index 0000000..06a5f7d
--- /dev/null
+++ b/Web/Phantom.Web.Services/Users/UserRoleManager.cs
@@ -0,0 +1,26 @@
+using System.Collections.Immutable;
+using Phantom.Common.Data.Web.Users;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Web.Services.Rpc;
+
+namespace Phantom.Web.Services.Users;
+
+public sealed class UserRoleManager {
+	private readonly ControllerConnection controllerConnection;
+	
+	public UserRoleManager(ControllerConnection controllerConnection) {
+		this.controllerConnection = controllerConnection;
+	}
+
+	public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(ImmutableHashSet<Guid> userGuids, CancellationToken cancellationToken) {
+		return controllerConnection.Send<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(new GetUserRolesMessage(userGuids), cancellationToken);
+	}
+	
+	public async Task<ImmutableArray<Guid>> GetUserRoles(Guid userGuid, CancellationToken cancellationToken) {
+		return (await GetUserRoles(ImmutableHashSet.Create(userGuid), cancellationToken)).GetValueOrDefault(userGuid, ImmutableArray<Guid>.Empty);
+	}
+
+	public Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids, CancellationToken cancellationToken) {
+		return controllerConnection.Send<ChangeUserRolesMessage, ChangeUserRolesResult>(new ChangeUserRolesMessage(loggedInUserGuid, subjectUserGuid, addToRoleGuids, removeFromRoleGuids), cancellationToken);
+	}
+}
diff --git a/Web/Phantom.Web/App.razor b/Web/Phantom.Web/App.razor
index e3a2445..43fa75a 100644
--- a/Web/Phantom.Web/App.razor
+++ b/Web/Phantom.Web/App.razor
@@ -1,5 +1,4 @@
-@using Phantom.Web.Identity.Interfaces
-@using Phantom.Web.Identity.Authentication
+@using Phantom.Web.Services
 @inject INavigation Nav
 @inject NavigationManager NavigationManager
 
@@ -8,14 +7,14 @@
     <Found Context="routeData">
       <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
         <NotAuthorized>
-          @if (!PhantomLoginManager.IsAuthenticated(context.User)) {
-            var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/');
-            Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true);
-          }
-          else {
+          @if (context.User.Identity is { IsAuthenticated: true }) {
             <h1>Forbidden</h1>
             <p role="alert">You do not have permission to visit this page.</p>
           }
+          else {
+            var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/');
+            Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true);
+          }
         </NotAuthorized>
       </AuthorizeRouteView>
       <FocusOnNavigate RouteData="@routeData" Selector="h1" />
diff --git a/Web/Phantom.Web/Base/LoginEvents.cs b/Web/Phantom.Web/Base/LoginEvents.cs
deleted file mode 100644
index ce7212e..0000000
--- a/Web/Phantom.Web/Base/LoginEvents.cs
+++ /dev/null
@@ -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);
-	}
-}
diff --git a/Web/Phantom.Web/Base/Navigation.cs b/Web/Phantom.Web/Base/Navigation.cs
index d6693db..f7c704d 100644
--- a/Web/Phantom.Web/Base/Navigation.cs
+++ b/Web/Phantom.Web/Base/Navigation.cs
@@ -1,7 +1,8 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Web;
 using Microsoft.AspNetCore.Components;
-using Phantom.Web.Identity.Interfaces;
+using Microsoft.AspNetCore.Components.Routing;
+using Phantom.Web.Services;
 
 namespace Phantom.Web.Base;
 
@@ -27,7 +28,24 @@ sealed class Navigation : INavigation {
 		return value != null;
 	}
 	
-	public void NavigateTo(string url, bool forceLoad = false) {
-		navigationManager.NavigateTo(BasePath + url, forceLoad);
+	public async Task NavigateTo(string url, bool forceLoad = false) {
+		var newPath = BasePath + url;
+		
+		var navigationTaskSource = new TaskCompletionSource();
+		navigationManager.LocationChanged += NavigationManagerOnLocationChanged;
+		try {
+			navigationManager.NavigateTo(newPath, forceLoad);
+			await navigationTaskSource.Task.WaitAsync(TimeSpan.FromSeconds(10));
+		} finally {
+			navigationManager.LocationChanged -= NavigationManagerOnLocationChanged;
+		}
+
+		return;
+
+		void NavigationManagerOnLocationChanged(object? sender, LocationChangedEventArgs e) {
+			if (Uri.TryCreate(e.Location, UriKind.Absolute, out var uri) && uri.AbsolutePath == newPath) {
+				navigationTaskSource.SetResult();
+			}
+		}
 	}
 }
diff --git a/Web/Phantom.Web/Base/PhantomComponent.cs b/Web/Phantom.Web/Base/PhantomComponent.cs
index 3e8b727..f401941 100644
--- a/Web/Phantom.Web/Base/PhantomComponent.cs
+++ b/Web/Phantom.Web/Base/PhantomComponent.cs
@@ -1,13 +1,14 @@
 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;
+using UserInfo = Phantom.Web.Services.Authentication.UserInfo;
 
 namespace Phantom.Web.Base;
 
-public abstract class PhantomComponent : ComponentBase {
+public abstract class PhantomComponent : ComponentBase, IDisposable {
 	private static readonly ILogger Logger = PhantomLogger.Create<PhantomComponent>();
 	
 	[CascadingParameter]
@@ -16,12 +17,30 @@ public abstract class PhantomComponent : ComponentBase {
 	[Inject]
 	public PermissionManager PermissionManager { get; set; } = null!;
 
+	private readonly CancellationTokenSource cancellationTokenSource = new ();
+
+	protected CancellationToken CancellationToken => cancellationTokenSource.Token;
+
+	protected async Task<Guid?> GetUserGuid() {
+		var authenticationState = await AuthenticationStateTask;
+		return UserInfo.TryGetGuid(authenticationState.User);
+	}
+
 	protected async Task<bool> CheckPermission(Permission permission) {
 		var authenticationState = await AuthenticationStateTask;
-		return PermissionManager.CheckPermission(authenticationState.User, permission, refreshCache: true);
+		return PermissionManager.CheckPermission(authenticationState.User, permission);
 	}
 
 	protected void InvokeAsyncChecked(Func<Task> task) {
 		InvokeAsync(task).ContinueWith(static t => Logger.Error(t.Exception, "Caught exception in async task."), TaskContinuationOptions.OnlyOnFaulted);
 	}
+
+	public void Dispose() {
+		cancellationTokenSource.Cancel();
+		cancellationTokenSource.Dispose();
+		OnDisposed();
+		GC.SuppressFinalize(this);
+	}
+
+	protected virtual void OnDisposed() {}
 }
diff --git a/Web/Phantom.Web/Configuration.cs b/Web/Phantom.Web/Configuration.cs
index 00826ee..3ce1ceb 100644
--- a/Web/Phantom.Web/Configuration.cs
+++ b/Web/Phantom.Web/Configuration.cs
@@ -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;
 }
diff --git a/Web/Phantom.Web/Layout/NavMenu.razor b/Web/Phantom.Web/Layout/NavMenu.razor
index 2eda009..6d78ae4 100644
--- a/Web/Phantom.Web/Layout/NavMenu.razor
+++ b/Web/Phantom.Web/Layout/NavMenu.razor
@@ -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
 
diff --git a/Web/Phantom.Web/Layout/_Error.cshtml b/Web/Phantom.Web/Layout/_Error.cshtml
index 0781ade..1e6e42f 100644
--- a/Web/Phantom.Web/Layout/_Error.cshtml
+++ b/Web/Phantom.Web/Layout/_Error.cshtml
@@ -1,5 +1,5 @@
 @page
-@using Phantom.Web.Identity.Interfaces
+@using Phantom.Web.Services
 @model Phantom.Web.Layout.ErrorModel
 @inject INavigation Navigation
 
diff --git a/Web/Phantom.Web/Layout/_Layout.cshtml b/Web/Phantom.Web/Layout/_Layout.cshtml
index a6f59e9..6791c4d 100644
--- a/Web/Phantom.Web/Layout/_Layout.cshtml
+++ b/Web/Phantom.Web/Layout/_Layout.cshtml
@@ -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
diff --git a/Web/Phantom.Web/Pages/Agents.razor b/Web/Phantom.Web/Pages/Agents.razor
index 57c20dd..a5e80dc 100644
--- a/Web/Phantom.Web/Pages/Agents.razor
+++ b/Web/Phantom.Web/Pages/Agents.razor
@@ -1,7 +1,8 @@
 @page "/agents"
-@using Phantom.Controller.Services.Agents
+@using Phantom.Common.Data.Web.Agent
 @using Phantom.Utils.Collections
-@implements IDisposable
+@using Phantom.Web.Services.Agents
+@inherits PhantomComponent
 @inject AgentManager AgentManager
 
 <h1>Agents</h1>
@@ -23,7 +24,7 @@
       @foreach (var agent in agentTable) {
         var usedInstances = agent.Stats?.RunningInstanceCount;
         var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
-        
+      
         <tr>
           <td>@agent.Name</td>
           <td class="text-end">
@@ -74,7 +75,7 @@
 
 @code {
 
-  private readonly Table<Agent, Guid> agentTable = new();
+  private readonly Table<AgentWithStats, Guid> agentTable = new();
 
   protected override void OnInitialized() {
     AgentManager.AgentsChanged.Subscribe(this, agents => {
@@ -84,7 +85,7 @@
     });
   }
 
-  void IDisposable.Dispose() {
+  protected override void OnDisposed() {
     AgentManager.AgentsChanged.Unsubscribe(this);
   }
 
diff --git a/Web/Phantom.Web/Pages/Audit.razor b/Web/Phantom.Web/Pages/Audit.razor
index 41f570c..e334fa0 100644
--- a/Web/Phantom.Web/Pages/Audit.razor
+++ b/Web/Phantom.Web/Pages/Audit.razor
@@ -1,12 +1,12 @@
 @page "/audit"
 @attribute [Authorize(Permission.ViewAuditPolicy)]
-@using Phantom.Controller.Services.Audit
-@using Phantom.Controller.Services.Instances
-@using Phantom.Controller.Services.Users
 @using System.Collections.Immutable
-@using Phantom.Controller.Database.Enums
-@implements IDisposable
-@inject AuditLog AuditLog
+@using Phantom.Common.Data.Web.AuditLog
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Users
+@using Phantom.Web.Services.Instances
+@inherits PhantomComponent
+@inject AuditLogManager AuditLogManager
 @inject InstanceManager InstanceManager
 @inject UserManager UserManager
 
@@ -43,7 +43,7 @@
           <code class="text-uppercase">@(logItem.SubjectId ?? "-")</code>
         </td>
         <td>
-          <code>@logItem.Data?.RootElement.ToString()</code>
+          <code>@logItem.JsonData</code>
         </td>
       </tr>
     }
@@ -53,8 +53,8 @@
 @code {
 
   private CancellationTokenSource? initializationCancellationTokenSource;
-  private AuditLogItem[] logItems = Array.Empty<AuditLogItem>();
-  private Dictionary<Guid, string>? userNamesByGuid;
+  private ImmutableArray<AuditLogItem> logItems = ImmutableArray<AuditLogItem>.Empty;
+  private ImmutableDictionary<Guid, string>? userNamesByGuid;
   private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 
   protected override async Task OnInitializedAsync() {
@@ -62,14 +62,14 @@
     var cancellationToken = initializationCancellationTokenSource.Token;
 
     try {
-      logItems = await AuditLog.GetItems(50, cancellationToken);
-      userNamesByGuid = await UserManager.GetAllByGuid(static user => user.Name, cancellationToken);
-      instanceNamesByGuid = InstanceManager.GetInstanceNames();
+      logItems = await AuditLogManager.GetMostRecentItems(50, cancellationToken);
+      userNamesByGuid = (await UserManager.GetAll(cancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
+      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName);
     } finally {
       initializationCancellationTokenSource.Dispose();
     }
   }
-  
+
   private string? GetSubjectName(AuditLogSubjectType type, string id) {
     return type switch {
            AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
@@ -78,7 +78,7 @@
     };
   }
 
-  public void Dispose() {
+  protected override void OnDisposed() {
     try {
       initializationCancellationTokenSource?.Cancel();
     } catch (ObjectDisposedException) {}
diff --git a/Web/Phantom.Web/Pages/Events.razor b/Web/Phantom.Web/Pages/Events.razor
index d6f48a3..41527b7 100644
--- a/Web/Phantom.Web/Pages/Events.razor
+++ b/Web/Phantom.Web/Pages/Events.razor
@@ -1,13 +1,14 @@
 @page "/events"
 @attribute [Authorize(Permission.ViewEventsPolicy)]
 @using System.Collections.Immutable
-@using Phantom.Controller.Services.Events
-@using Phantom.Controller.Services.Instances
-@using Phantom.Controller.Database.Enums
-@using Phantom.Controller.Services.Agents
-@implements IDisposable
+@using Phantom.Common.Data.Web.EventLog
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Agents
+@using Phantom.Web.Services.Events
+@using Phantom.Web.Services.Instances
+@inherits PhantomComponent
 @inject AgentManager AgentManager
-@inject EventLog EventLog
+@inject EventLogManager EventLogManager
 @inject InstanceManager InstanceManager
 
 <h1>Event Log</h1>
@@ -48,7 +49,7 @@
           <code class="text-uppercase">@logItem.SubjectId</code>
         </td>
         <td>
-          <code>@logItem.Data?.RootElement.ToString()</code>
+          <code>@logItem.JsonData</code>
         </td>
       </tr>
     }
@@ -58,7 +59,7 @@
 @code {
 
   private CancellationTokenSource? initializationCancellationTokenSource;
-  private EventLogItem[] logItems = Array.Empty<EventLogItem>();
+  private ImmutableArray<EventLogItem> logItems = ImmutableArray<EventLogItem>.Empty;
   private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
   private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 
@@ -67,9 +68,9 @@
     var cancellationToken = initializationCancellationTokenSource.Token;
 
     try {
-      logItems = await EventLog.GetItems(50, cancellationToken);
-      agentNamesByGuid = AgentManager.GetAgents().ToImmutableDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Name);
-      instanceNamesByGuid = InstanceManager.GetInstanceNames();
+      logItems = await EventLogManager.GetMostRecentItems(50, cancellationToken);
+      agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.Guid, static kvp => kvp.Name);
+      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName);
     } finally {
       initializationCancellationTokenSource.Dispose();
     }
@@ -78,7 +79,7 @@
   private string GetAgentName(Guid agentGuid) {
     return agentNamesByGuid.TryGetValue(agentGuid, out var name) ? name : "?";
   }
-  
+
   private string? GetSubjectName(EventLogSubjectType type, string id) {
     return type switch {
            EventLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
@@ -86,7 +87,7 @@
     };
   }
 
-  public void Dispose() {
+  protected override void OnDisposed() {
     try {
       initializationCancellationTokenSource?.Cancel();
     } catch (ObjectDisposedException) {}
diff --git a/Web/Phantom.Web/Pages/InstanceCreate.razor b/Web/Phantom.Web/Pages/InstanceCreate.razor
index fc38613..c1baee6 100644
--- a/Web/Phantom.Web/Pages/InstanceCreate.razor
+++ b/Web/Phantom.Web/Pages/InstanceCreate.razor
@@ -1,4 +1,5 @@
 @page "/instances/create"
+@using Phantom.Common.Data.Web.Users
 @attribute [Authorize(Permission.CreateInstancesPolicy)]
 
 <h1>New Instance</h1>
diff --git a/Web/Phantom.Web/Pages/InstanceDetail.razor b/Web/Phantom.Web/Pages/InstanceDetail.razor
index 3cd0f06..23b5991 100644
--- a/Web/Phantom.Web/Pages/InstanceDetail.razor
+++ b/Web/Phantom.Web/Pages/InstanceDetail.razor
@@ -1,13 +1,13 @@
 @page "/instances/{InstanceGuid:guid}"
 @attribute [Authorize(Permission.ViewInstancesPolicy)]
-@inherits PhantomComponent
 @using Phantom.Common.Data.Instance
 @using Phantom.Common.Data.Replies
-@using Phantom.Controller.Services.Audit
-@using Phantom.Controller.Services.Instances
-@implements IDisposable
+@using Phantom.Common.Data.Web.Instance
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Instances
+@using Phantom.Web.Services.Authorization
+@inherits PhantomComponent
 @inject InstanceManager InstanceManager
-@inject AuditLog AuditLog
 
 @if (Instance == null) {
   <h1>Instance Not Found</h1>
@@ -46,7 +46,7 @@ else {
 @code {
 
   [Parameter]
-  public Guid InstanceGuid { get; set; }
+  public Guid InstanceGuid { get; init; }
 
   private string? lastError = null;
   private bool isLaunchingInstance = false;
@@ -68,16 +68,14 @@ else {
     lastError = null;
 
     try {
-      if (!await CheckPermission(Permission.ControlInstances)) {
+      var loggedInUserGuid = await GetUserGuid();
+      if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
         lastError = "You do not have permission to launch instances.";
         return;
       }
 
-      var result = await InstanceManager.LaunchInstance(InstanceGuid);
-      if (result.Is(LaunchInstanceResult.LaunchInitiated)) {
-        await AuditLog.AddInstanceLaunchedEvent(InstanceGuid);
-      }
-      else {
+      var result = await InstanceManager.LaunchInstance(loggedInUserGuid.Value, InstanceGuid, CancellationToken);
+      if (!result.Is(LaunchInstanceResult.LaunchInitiated)) {
         lastError = result.ToSentence(Messages.ToSentence);
       }
     } finally {
@@ -85,7 +83,7 @@ else {
     }
   }
 
-  public void Dispose() {
+  protected override void OnDisposed() {
     InstanceManager.InstancesChanged.Unsubscribe(this);
   }
 
diff --git a/Web/Phantom.Web/Pages/InstanceEdit.razor b/Web/Phantom.Web/Pages/InstanceEdit.razor
index f8c142b..270f25b 100644
--- a/Web/Phantom.Web/Pages/InstanceEdit.razor
+++ b/Web/Phantom.Web/Pages/InstanceEdit.razor
@@ -1,7 +1,8 @@
 @page "/instances/{InstanceGuid:guid}/edit"
 @attribute [Authorize(Permission.CreateInstancesPolicy)]
 @using Phantom.Common.Data.Instance
-@using Phantom.Controller.Services.Instances
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Instances
 @inherits PhantomComponent
 @inject InstanceManager InstanceManager
 
@@ -17,12 +18,12 @@ else {
 @code {
 
   [Parameter]
-  public Guid InstanceGuid { get; set; }
+  public Guid InstanceGuid { get; init; }
 
   private InstanceConfiguration? InstanceConfiguration { get; set; }
 
   protected override void OnInitialized() {
-    InstanceConfiguration = InstanceManager.GetInstanceConfiguration(InstanceGuid);
+    InstanceConfiguration = InstanceManager.GetByGuid(InstanceGuid)?.Configuration;
   }
 
 }
diff --git a/Web/Phantom.Web/Pages/Instances.razor b/Web/Phantom.Web/Pages/Instances.razor
index e47b9a0..c4ad3c9 100644
--- a/Web/Phantom.Web/Pages/Instances.razor
+++ b/Web/Phantom.Web/Pages/Instances.razor
@@ -1,9 +1,12 @@
 @page "/instances"
 @attribute [Authorize(Permission.ViewInstancesPolicy)]
 @using System.Collections.Immutable
-@using Phantom.Controller.Services.Instances
-@using Phantom.Controller.Services.Agents
-@implements IDisposable
+@using Phantom.Common.Data.Web.Instance
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Agents
+@using Phantom.Web.Services.Authorization
+@using Phantom.Web.Services.Instances
+@inherits PhantomComponent
 @inject AgentManager AgentManager
 @inject InstanceManager InstanceManager
 
@@ -30,7 +33,7 @@
   @if (!instances.IsEmpty) {
     <tbody>
       @foreach (var (configuration, status, _) in instances) {
-        var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty;
+        var agentName = agentNamesByGuid.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty;
         var instanceGuid = configuration.InstanceGuid.ToString();
         <tr>
           <td>@agentName</td>
@@ -71,22 +74,25 @@
 
 @code {
 
-  private ImmutableDictionary<Guid, string> agentNames = ImmutableDictionary<Guid, string>.Empty;
+  private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
   private ImmutableArray<Instance> instances = ImmutableArray<Instance>.Empty;
 
   protected override void OnInitialized() {
     AgentManager.AgentsChanged.Subscribe(this, agents => {
-      this.agentNames = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name);
+      this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name);
       InvokeAsync(StateHasChanged);
     });
 
     InstanceManager.InstancesChanged.Subscribe(this, instances => {
-      this.instances = instances.Values.OrderBy(instance => agentNames.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty).ThenBy(static instance => instance.Configuration.InstanceName).ToImmutableArray();
+      this.instances = instances.Values
+                                .OrderBy(instance => agentNamesByGuid.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty)
+                                .ThenBy(static instance => instance.Configuration.InstanceName)
+                                .ToImmutableArray();
       InvokeAsync(StateHasChanged);
     });
   }
 
-  void IDisposable.Dispose() {
+  protected override void OnDisposed() {
     AgentManager.AgentsChanged.Unsubscribe(this);
     InstanceManager.InstancesChanged.Unsubscribe(this);
   }
diff --git a/Web/Phantom.Web/Pages/Login.razor b/Web/Phantom.Web/Pages/Login.razor
index f1748e6..87001ed 100644
--- a/Web/Phantom.Web/Pages/Login.razor
+++ b/Web/Phantom.Web/Pages/Login.razor
@@ -1,10 +1,10 @@
 @page "/login"
-@using Phantom.Web.Identity.Interfaces
-@using Phantom.Web.Identity.Authentication
+@using Phantom.Web.Services
+@using Phantom.Web.Services.Authentication
 @using System.ComponentModel.DataAnnotations
 @attribute [AllowAnonymous]
 @inject INavigation Navigation
-@inject PhantomLoginManager LoginManager
+@inject UserLoginManager LoginManager
 
 <h1>Login</h1>
 
@@ -51,7 +51,7 @@
     
     string? returnUrl = Navigation.GetQueryParameter("return", out var url) ? url : null;
     
-    if (!await LoginManager.SignIn(form.Username, form.Password, returnUrl)) {
+    if (!await LoginManager.LogIn(form.Username, form.Password, returnUrl)) {
       form.SubmitModel.StopSubmitting("Invalid username or password.");
     }
   }
diff --git a/Web/Phantom.Web/Pages/Logout.razor b/Web/Phantom.Web/Pages/Logout.razor
new file mode 100644
index 0000000..d2f277c
--- /dev/null
+++ b/Web/Phantom.Web/Pages/Logout.razor
@@ -0,0 +1,11 @@
+@page "/logout"
+@using Phantom.Web.Services.Authentication
+@inject UserLoginManager LoginManager
+
+@code {
+
+  protected override Task OnInitializedAsync() {
+    return LoginManager.LogOut();
+  }
+
+}
diff --git a/Web/Phantom.Web/Pages/Setup.razor b/Web/Phantom.Web/Pages/Setup.razor
index cb01807..de89aa6 100644
--- a/Web/Phantom.Web/Pages/Setup.razor
+++ b/Web/Phantom.Web/Pages/Setup.razor
@@ -1,20 +1,17 @@
 @page "/setup"
-@using Phantom.Controller.Services.Users
-@using Phantom.Utils.Cryptography
 @using Phantom.Utils.Tasks
-@using Phantom.Controller.Database.Entities
-@using Phantom.Controller.Services
-@using Phantom.Controller.Services.Audit
-@using Phantom.Web.Identity.Authentication
+@using Phantom.Web.Services.Authentication
+@using Phantom.Web.Services.Rpc
 @using System.ComponentModel.DataAnnotations
+@using Phantom.Utils.Cryptography
 @using System.Security.Cryptography
+@using Phantom.Common.Messages.Web.ToController
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults
 @attribute [AllowAnonymous]
 @inject ServiceConfiguration ServiceConfiguration
-@inject PhantomLoginManager LoginManager
-@inject UserManager UserManager
-@inject RoleManager RoleManager
-@inject UserRoleManager UserRoleManager
-@inject AuditLog AuditLog
+@inject UserLoginManager LoginManager
+@inject ControllerConnection ControllerConnection
 
 <h1>Administrator Setup</h1>
 
@@ -72,7 +69,7 @@
       return;
     }
 
-    var signInResult = await LoginManager.SignIn(form.Username, form.Password);
+    var signInResult = await LoginManager.LogIn(form.Username, form.Password);
     if (!signInResult) {
       form.SubmitModel.StopSubmitting("Error logging in.");
     }
@@ -90,45 +87,15 @@
   }
 
   private async Task<Result<string>> CreateOrUpdateAdministrator() {
-    var existingUser = await UserManager.GetByName(form.Username);
-    return existingUser == null ? await CreateAdministrator() : await UpdateAdministrator(existingUser);
-  }
-
-  private async Task<Result<string>> CreateAdministrator() {
-    var administratorRole = await RoleManager.GetByGuid(Role.Administrator.Guid);
-    if (administratorRole == null) {
-      return Result.Fail("Administrator role not found.");
-    }
-
-    switch (await UserManager.CreateUser(form.Username, form.Password)) {
-      case Result<UserEntity, AddUserError>.Ok ok:
-        var administratorUser = ok.Value;
-        await AuditLog.AddAdministratorUserCreatedEvent(administratorUser);
-
-        if (!await UserRoleManager.Add(administratorUser, administratorRole)) {
-          return Result.Fail("Could not assign administrator role to user.");
-        }
-
-        return Result.Ok<string>();
-
-      case Result<UserEntity, AddUserError>.Fail fail:
-        return Result.Fail(fail.Error.ToSentences("\n"));
-    }
-
-    return Result.Fail("Unknown error.");
-  }
-
-  private async Task<Result<string>> UpdateAdministrator(UserEntity existingUser) {
-    switch (await UserManager.SetUserPassword(existingUser.UserGuid, form.Password)) {
-      case Result<SetUserPasswordError>.Ok:
-        await AuditLog.AddAdministratorUserModifiedEvent(existingUser);
-        return Result.Ok<string>();
-
-      case Result<SetUserPasswordError>.Fail fail:
-        return Result.Fail(fail.Error.ToSentences("\n"));
-    }
-
-    return Result.Fail("Unknown error.");
+    var reply = await ControllerConnection.Send<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUserMessage(form.Username, form.Password), Timeout.InfiniteTimeSpan);
+    return reply switch {
+           Success             => Result.Ok<string>(),
+           CreationFailed fail => fail.Error.ToSentences("\n"),
+           UpdatingFailed fail => fail.Error.ToSentences("\n"),
+           AddingToRoleFailed  => "Could not assign administrator role to user.",
+           null                => "Timed out.",
+           _                   => "Unknown error."
+    };
   }
 
 }
diff --git a/Web/Phantom.Web/Pages/Users.razor b/Web/Phantom.Web/Pages/Users.razor
index a4cca10..45f59bc 100644
--- a/Web/Phantom.Web/Pages/Users.razor
+++ b/Web/Phantom.Web/Pages/Users.razor
@@ -1,11 +1,13 @@
 @page "/users"
-@using Phantom.Controller.Database.Entities
-@using Phantom.Controller.Services.Users
-@using System.Collections.Immutable
 @attribute [Authorize(Permission.ViewUsersPolicy)]
+@using System.Collections.Immutable
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Users
+@using Phantom.Web.Services.Authorization
+@inherits PhantomComponent
 @inject UserManager UserManager
+@inject RoleManager RoleManager
 @inject UserRoleManager UserRoleManager
-@inject PermissionManager PermissionManager
 
 <h1>Users</h1>
 
@@ -28,12 +30,11 @@
         </tr>
       </thead>
       <tbody>
-        @{ var myUserId = UserManager.GetAuthenticatedUserId(context.User); }
         @foreach (var user in allUsers) {
-          var isMe = myUserId == user.UserGuid;
+          var isMe = me == user.Guid;
           <tr>
             <td>
-              <code class="text-uppercase">@user.UserGuid</code>
+              <code class="text-uppercase">@user.Guid</code>
             </td>
             @if (isMe) {
               <td class="fw-semibold">@user.Name</td>
@@ -41,7 +42,7 @@
             else {
               <td>@user.Name</td>
             }
-            <td>@(userGuidToRoleDescription.TryGetValue(user.UserGuid, out var roles) ? roles : "?")</td>
+            <td>@(userGuidToRoleDescription.TryGetValue(user.Guid, out var roles) ? roles : "?")</td>
             @if (canEdit) {
               <td>
                 @if (!isMe) {
@@ -65,46 +66,53 @@
 
 @code {
 
-  private ImmutableArray<UserEntity> allUsers = ImmutableArray<UserEntity>.Empty;
+  private Guid? me = Guid.Empty;
+  private ImmutableArray<UserInfo> allUsers = ImmutableArray<UserInfo>.Empty;
+  private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty;
   private readonly Dictionary<Guid, string> userGuidToRoleDescription = new();
 
   private UserRolesDialog userRolesDialog = null!;
   private UserDeleteDialog userDeleteDialog = null!;
 
   protected override async Task OnInitializedAsync() {
-    var unsortedUsers = await UserManager.GetAll();
-    allUsers = unsortedUsers.Sort(static (a, b) => a.Name.CompareTo(b.Name));
+    me = await GetUserGuid();
+
+    allUsers = (await UserManager.GetAll(CancellationToken)).Sort(static (a, b) => a.Name.CompareTo(b.Name));
+    allRolesByGuid = (await RoleManager.GetAll(CancellationToken)).ToImmutableDictionary(static role => role.Guid, static role => role);
+
+    var allUserGuids = allUsers
+        .Select(static user => user.Guid)
+        .ToImmutableHashSet();
     
-    foreach (var (userGuid, roles) in await UserRoleManager.GetAllByUserGuid()) {
-      userGuidToRoleDescription[userGuid] = StringifyRoles(roles);
-    }
-    
-    foreach (var user in allUsers) {
-      await RefreshUserRoles(user);
+    foreach (var (userGuid, roleGuids) in await UserRoleManager.GetUserRoles(allUserGuids, CancellationToken)) {
+      userGuidToRoleDescription[userGuid] = StringifyRoles(roleGuids);
     }
   }
 
-  private async Task RefreshUserRoles(UserEntity user) {
-    var roles = await UserRoleManager.GetUserRoles(user);
-    userGuidToRoleDescription[user.UserGuid] = StringifyRoles(roles);
+  private async Task RefreshUserRoles(UserInfo user) {
+    userGuidToRoleDescription[user.Guid] = StringifyRoles(await UserRoleManager.GetUserRoles(user.Guid, CancellationToken));
   }
 
-  private static string StringifyRoles(ImmutableArray<RoleEntity> roles) {
-    return roles.IsEmpty ? "-" : string.Join(", ", roles.Select(static role => role.Name));
+  private string StringifyRoles(ImmutableArray<Guid> roleGuids) {
+    return roleGuids.IsEmpty ? "-" : string.Join(", ", roleGuids.Select(StringifyRole));
   }
 
-  private Task OnUserAdded(UserEntity user) {
+  private string StringifyRole(Guid role) {
+    return allRolesByGuid.TryGetValue(role, out var roleInfo) ? roleInfo.Name : "?";
+  }
+
+  private Task OnUserAdded(UserInfo user) {
     allUsers = allUsers.Add(user);
     return RefreshUserRoles(user);
   }
 
-  private Task OnUserRolesChanged(UserEntity user) {
+  private Task OnUserRolesChanged(UserInfo user) {
     return RefreshUserRoles(user);
   }
 
-  private void OnUserDeleted(UserEntity user) {
+  private void OnUserDeleted(UserInfo user) {
     allUsers = allUsers.Remove(user);
-    userGuidToRoleDescription.Remove(user.UserGuid);
+    userGuidToRoleDescription.Remove(user.Guid);
   }
 
 }
diff --git a/Web/Phantom.Web/Phantom.Web.csproj b/Web/Phantom.Web/Phantom.Web.csproj
index f37ccd2..89bf413 100644
--- a/Web/Phantom.Web/Phantom.Web.csproj
+++ b/Web/Phantom.Web/Phantom.Web.csproj
@@ -20,8 +20,9 @@
   </ItemGroup>
   
   <ItemGroup>
+    <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
     <ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
-    <ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" />
+    <ProjectReference Include="..\Phantom.Web.Services\Phantom.Web.Services.csproj" />
   </ItemGroup>
   
 </Project>
diff --git a/Web/Phantom.Web/Program.cs b/Web/Phantom.Web/Program.cs
new file mode 100644
index 0000000..7def433
--- /dev/null
+++ b/Web/Phantom.Web/Program.cs
@@ -0,0 +1,107 @@
+using System.Reflection;
+using NetMQ;
+using Phantom.Common.Logging;
+using Phantom.Common.Messages.Web;
+using Phantom.Common.Messages.Web.ToController;
+using Phantom.Utils.Cryptography;
+using Phantom.Utils.IO;
+using Phantom.Utils.Rpc;
+using Phantom.Utils.Rpc.Sockets;
+using Phantom.Utils.Runtime;
+using Phantom.Utils.Tasks;
+using Phantom.Web;
+using Phantom.Web.Services.Rpc;
+
+var shutdownCancellationTokenSource = new CancellationTokenSource();
+var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
+
+PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
+	PhantomLogger.Root.InformationHeading("Stopping Phantom Panel web...");
+});
+
+static void CreateFolderOrStop(string path, UnixFileMode chmod) {
+	if (!Directory.Exists(path)) {
+		try {
+			Directories.Create(path, chmod);
+		} catch (Exception e) {
+			PhantomLogger.Root.Fatal(e, "Error creating folder: {FolderName}", path);
+			throw StopProcedureException.Instance;
+		}
+	}
+}
+
+try {
+	var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
+
+	PhantomLogger.Root.InformationHeading("Initializing Phantom Panel web...");
+	PhantomLogger.Root.Information("Web version: {Version}", fullVersion);
+
+	var (controllerHost, controllerPort, webKeyToken, webKeyFilePath, webServerHost, webServerPort, webBasePath) = Variables.LoadOrStop();
+
+	var webKey = await WebKey.Load(webKeyToken, webKeyFilePath);
+	if (webKey == null) {
+		return 1;
+	}
+	
+	string dataProtectionKeysPath = Path.GetFullPath("./keys");
+	CreateFolderOrStop(dataProtectionKeysPath, Chmod.URWX);
+
+	var (controllerCertificate, webToken) = webKey.Value;
+	
+	var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate);
+	var rpcSocket = RpcClientSocket.Connect(rpcConfiguration, WebMessageRegistries.Definitions, new RegisterWebMessage(webToken));
+	
+	var configuration = new Configuration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, dataProtectionKeysPath, shutdownCancellationToken);
+	var administratorToken = TokenGenerator.Create(60);
+
+	var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Web"));
+	var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), shutdownCancellationToken);
+	var webApplication = WebLauncher.CreateApplication(configuration, taskManager, serviceConfiguration, rpcSocket.Connection);
+
+	MessageListener messageListener;
+	await using (var scope = webApplication.Services.CreateAsyncScope()) {
+		messageListener = scope.ServiceProvider.GetRequiredService<MessageListener>();
+	}
+
+	var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
+	var rpcTask = RpcClientRuntime.Launch(rpcSocket, messageListener, rpcDisconnectSemaphore, shutdownCancellationToken);
+	try {
+		PhantomLogger.Root.Information("Registering with the controller...");
+		if (await messageListener.RegisterSuccessWaiter.Task) {
+			PhantomLogger.Root.Information("Successfully registered with the controller.");
+		}
+		else {
+			PhantomLogger.Root.Fatal("Failed to register with the controller.");
+			return 1;
+		}
+		
+		PhantomLogger.Root.InformationHeading("Launching Phantom Panel web...");
+		PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken);
+		PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", configuration.HttpUrl, configuration.BasePath + "setup");
+		
+		await WebLauncher.Launch(configuration, webApplication);
+	} finally {
+		shutdownCancellationTokenSource.Cancel();
+		await taskManager.Stop();
+		
+		rpcDisconnectSemaphore.Release();
+		await rpcTask;
+		rpcDisconnectSemaphore.Dispose();
+		
+		NetMQConfig.Cleanup();
+	}
+
+	return 0;
+} catch (OperationCanceledException) {
+	return 0;
+} catch (StopProcedureException) {
+	return 1;
+} catch (Exception e) {
+	PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
+	return 1;
+} finally {
+	shutdownCancellationTokenSource.Dispose();
+	
+	PhantomLogger.Root.Information("Bye!");
+	PhantomLogger.Dispose();
+}
diff --git a/Web/Phantom.Web/ServiceConfiguration.cs b/Web/Phantom.Web/ServiceConfiguration.cs
new file mode 100644
index 0000000..10221ca
--- /dev/null
+++ b/Web/Phantom.Web/ServiceConfiguration.cs
@@ -0,0 +1,7 @@
+namespace Phantom.Web;
+
+public sealed record ServiceConfiguration(
+	string Version,
+	byte[] AdministratorToken,
+	CancellationToken CancellationToken
+);
diff --git a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
index 8006f62..2ae7d6c 100644
--- a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
+++ b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor
@@ -1,29 +1,32 @@
-@using System.Collections.Immutable
+@using Phantom.Web.Components.Utils
+@using System.Collections.Immutable
 @using System.ComponentModel.DataAnnotations
 @using System.Diagnostics.CodeAnalysis
-@using Phantom.Controller.Minecraft
-@using Phantom.Controller.Services.Agents
-@using Phantom.Controller.Services.Audit
-@using Phantom.Controller.Services.Instances
-@using Phantom.Web.Components.Utils
 @using Phantom.Common.Data.Minecraft
+@using Phantom.Common.Data.Web.Agent
+@using Phantom.Common.Data.Web.Instance
+@using Phantom.Common.Data.Web.Minecraft
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Common.Messages.Web.ToController
 @using Phantom.Common.Data.Instance
 @using Phantom.Common.Data.Java
 @using Phantom.Common.Data
-@using Phantom.Web.Identity.Interfaces
+@using Phantom.Web.Services
+@using Phantom.Web.Services.Agents
+@using Phantom.Web.Services.Instances
+@using Phantom.Web.Services.Rpc
+@inherits PhantomComponent
 @inject INavigation Nav
-@inject MinecraftVersions MinecraftVersions
+@inject ControllerConnection ControllerConnection
 @inject AgentManager AgentManager
-@inject AgentJavaRuntimesManager AgentJavaRuntimesManager
 @inject InstanceManager InstanceManager
-@inject AuditLog AuditLog
 
 <Form Model="form" OnSubmit="AddOrEditInstance">
   @{ var selectedAgent = form.SelectedAgent; }
   <div class="row">
     <div class="col-xl-7 mb-3">
       @{
-        static RenderFragment GetAgentOption(Agent agent) {
+        static RenderFragment GetAgentOption(AgentWithStats agent) {
           return @<option value="@agent.Guid">
                    @agent.Name
                    &bullet;
@@ -36,14 +39,14 @@
       @if (EditedInstanceConfiguration == null) {
         <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
           <option value="" selected>Select which agent will run the instance...</option>
-          @foreach (var agent in form.AgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) {
+          @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) {
             @GetAgentOption(agent)
           }
         </FormSelectInput>
       }
       else {
         <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid" disabled="true">
-          @if (form.SelectedAgentGuid is {} guid && form.AgentsByGuid.TryGetValue(guid, out var agent)) {
+          @if (form.SelectedAgentGuid is {} guid && allAgentsByGuid.TryGetValue(guid, out var agent)) {
             @GetAgentOption(agent)
           }
         </FormSelectInput>
@@ -162,23 +165,25 @@
 @code {
 
   [Parameter, EditorRequired]
-  public InstanceConfiguration? EditedInstanceConfiguration { get; set; }
+  public InstanceConfiguration? EditedInstanceConfiguration { get; init; }
 
   private ConfigureInstanceFormModel form = null!;
 
+  private ImmutableDictionary<Guid, AgentWithStats> allAgentsByGuid = ImmutableDictionary<Guid, AgentWithStats>.Empty;
+  private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> allAgentJavaRuntimes = ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>.Empty;
+  
   private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release;
+  private ImmutableArray<MinecraftVersion> allMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty;
   private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty;
 
   private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(ConfigureInstanceFormModel.SelectedAgentGuid))).Any();
 
   private sealed class ConfigureInstanceFormModel : FormModel {
-    public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; }
-    private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid;
+    private readonly InstanceAddOrEditForm page;
     private readonly RamAllocationUnits? editedInstanceRamAllocation;
 
-    public ConfigureInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, RamAllocationUnits? editedInstanceRamAllocation) {
-      this.AgentsByGuid = agentManager.GetAgents().ToImmutableDictionary();
-      this.javaRuntimesByAgentGuid = agentJavaRuntimesManager.All;
+    public ConfigureInstanceFormModel(InstanceAddOrEditForm page, RamAllocationUnits? editedInstanceRamAllocation) {
+      this.page = page;
       this.editedInstanceRamAllocation = editedInstanceRamAllocation;
     }
 
@@ -192,14 +197,14 @@
       }
     }
 
-    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) {
-      return TryGet(AgentsByGuid, agentGuid, out agent);
+    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out AgentWithStats? agent) {
+      return TryGet(page.allAgentsByGuid, agentGuid, out agent);
     }
 
-    public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null;
-    
-    public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(javaRuntimesByAgentGuid, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
-    
+    public AgentWithStats? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null;
+
+    public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
+
     public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0;
     public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits);
     private ushort selectedMemoryUnits = 4;
@@ -265,8 +270,17 @@
   }
 
   protected override void OnInitialized() {
-    form = new ConfigureInstanceFormModel(AgentManager, AgentJavaRuntimesManager, EditedInstanceConfiguration?.MemoryAllocation);
+    form = new ConfigureInstanceFormModel(this, EditedInstanceConfiguration?.MemoryAllocation);
+  }
+
+  protected override async Task OnInitializedAsync() {
+    var agentJavaRuntimesTask = ControllerConnection.Send<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(new GetAgentJavaRuntimesMessage(), TimeSpan.FromSeconds(30));
+    var minecraftVersionsTask = ControllerConnection.Send<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(new GetMinecraftVersionsMessage(), TimeSpan.FromSeconds(30));
     
+    allAgentsByGuid = AgentManager.ToDictionaryByGuid();
+    allAgentJavaRuntimes = await agentJavaRuntimesTask;
+    allMinecraftVersions = await minecraftVersionsTask;
+
     if (EditedInstanceConfiguration != null) {
       form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid;
       form.InstanceName = EditedInstanceConfiguration.InstanceName;
@@ -277,28 +291,21 @@
       form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue;
       form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid;
       form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments);
+      
+      minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType;
     }
-    
+
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits));
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.JavaRuntimeGuid));
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.ServerPort));
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.RconPort));
     form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.ServerPort), revalidated: nameof(ConfigureInstanceFormModel.RconPort));
+    
+    SetMinecraftVersionType(minecraftVersionType);
   }
 
-  protected override async Task OnInitializedAsync() {
-    if (EditedInstanceConfiguration != null) {
-      var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None);
-      minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType;
-    }
-
-    await SetMinecraftVersionType(minecraftVersionType);
-  }
-
-  private async Task SetMinecraftVersionType(MinecraftVersionType type) {
+  private void SetMinecraftVersionType(MinecraftVersionType type) {
     minecraftVersionType = type;
-
-    var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None);
     availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray();
 
     if (!availableMinecraftVersions.IsEmpty && !availableMinecraftVersions.Any(version => version.Id == form.MinecraftVersion)) {
@@ -314,6 +321,12 @@
 
     await form.SubmitModel.StartSubmitting();
 
+    var loggedInUserGuid = await GetUserGuid();
+    if (loggedInUserGuid == null || !await CheckPermission(Permission.CreateInstances)) {
+      form.SubmitModel.StopSubmitting("You do not have permission to edit instances.");
+      return;
+    }
+      
     var instance = new InstanceConfiguration(
       EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid,
       EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(),
@@ -327,14 +340,13 @@
       JvmArgumentsHelper.Split(form.JvmArguments)
     );
 
-    var result = await InstanceManager.AddOrEditInstance(instance);
-    if (result.Is(AddOrEditInstanceResult.Success)) {
-      await (EditedInstanceConfiguration == null ? AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid) : AuditLog.AddInstanceEditedEvent(instance.InstanceGuid));
-      Nav.NavigateTo("instances/" + instance.InstanceGuid);
+    var result = await InstanceManager.CreateOrUpdateInstance(loggedInUserGuid.Value, instance, CancellationToken);
+    if (result.Is(CreateOrUpdateInstanceResult.Success)) {
+      await Nav.NavigateTo("instances/" + instance.InstanceGuid);
     }
     else {
-      form.SubmitModel.StopSubmitting(result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
+      form.SubmitModel.StopSubmitting(result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence));
     }
   }
-  
+
 }
diff --git a/Web/Phantom.Web/Shared/InstanceCommandInput.razor b/Web/Phantom.Web/Shared/InstanceCommandInput.razor
index b7918b2..4303fa7 100644
--- a/Web/Phantom.Web/Shared/InstanceCommandInput.razor
+++ b/Web/Phantom.Web/Shared/InstanceCommandInput.razor
@@ -1,9 +1,8 @@
-@using Phantom.Controller.Services.Instances
-@using Phantom.Controller.Services.Audit
+@using Phantom.Web.Services.Instances
+@using Phantom.Common.Data.Web.Users
 @using Phantom.Common.Data.Replies
 @inherits PhantomComponent
 @inject InstanceManager InstanceManager
-@inject AuditLog AuditLog
 
 <Form Model="form" OnSubmit="ExecuteCommand">
   <label for="command-input" class="form-label">Instance Name</label>
@@ -16,7 +15,7 @@
 </Form>
 
 @code {
-  
+
   [Parameter]
   public Guid InstanceGuid { get; set; }
 
@@ -24,24 +23,24 @@
   public bool Disabled { get; set; }
 
   private readonly SendCommandFormModel form = new ();
-  
+
   private sealed class SendCommandFormModel : FormModel {
     public string Command { get; set; } = string.Empty;
   }
-  
+
   private ElementReference commandInputElement;
 
   private async Task ExecuteCommand(EditContext context) {
     await form.SubmitModel.StartSubmitting();
 
-    if (!await CheckPermission(Permission.ControlInstances)) {
+    var loggedInUserGuid = await GetUserGuid();
+    if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
       form.SubmitModel.StopSubmitting("You do not have permission to execute commands.");
       return;
     }
 
-    var result = await InstanceManager.SendCommand(InstanceGuid, form.Command);
+    var result = await InstanceManager.SendCommandToInstance(loggedInUserGuid.Value, InstanceGuid, form.Command, CancellationToken);
     if (result.Is(SendCommandToInstanceResult.Success)) {
-      await AuditLog.AddInstanceCommandExecutedEvent(InstanceGuid, form.Command);
       form.Command = string.Empty;
       form.SubmitModel.StopSubmitting();
     }
@@ -51,5 +50,5 @@
 
     await commandInputElement.FocusAsync(preventScroll: true);
   }
-  
+
 }
diff --git a/Web/Phantom.Web/Shared/InstanceLog.razor b/Web/Phantom.Web/Shared/InstanceLog.razor
index 65d2ff6..69ba35b 100644
--- a/Web/Phantom.Web/Shared/InstanceLog.razor
+++ b/Web/Phantom.Web/Shared/InstanceLog.razor
@@ -1,11 +1,11 @@
-@inherits PhantomComponent
-@using Phantom.Utils.Collections
+@using Phantom.Utils.Collections
 @using Phantom.Utils.Events
 @using System.Diagnostics
-@using Phantom.Controller.Services.Instances
-@implements IDisposable
+@using Phantom.Web.Services.Instances
+@using Phantom.Common.Data.Web.Users
+@inherits PhantomComponent
 @inject IJSRuntime Js;
-@inject InstanceLogManager InstanceLogManager
+@inject InstanceLogManager InstanceLogManager;
 
 <div id="log" class="font-monospace mb-3">
   @foreach (var line in instanceLogs.EnumerateLast(uint.MaxValue)) {
@@ -16,7 +16,7 @@
 @code {
 
   [Parameter, EditorRequired]
-  public Guid InstanceGuid { get; set; }
+  public Guid InstanceGuid { get; init; }
 
   private IJSObjectReference? PageJs { get; set; }
 
@@ -57,7 +57,7 @@
 
   private async Task RecheckPermissions() {
     recheckPermissionsStopwatch.Restart();
-    
+
     if (!await CheckPermission(Permission.ViewInstanceLogs)) {
       await Task.Yield();
       Dispose();
@@ -65,7 +65,7 @@
     }
   }
 
-  public void Dispose() {
+  protected override void OnDisposed() {
     instanceLogsSubs.Unsubscribe(this);
   }
 
diff --git a/Web/Phantom.Web/Shared/InstanceStopDialog.razor b/Web/Phantom.Web/Shared/InstanceStopDialog.razor
index 49152a1..336f6f7 100644
--- a/Web/Phantom.Web/Shared/InstanceStopDialog.razor
+++ b/Web/Phantom.Web/Shared/InstanceStopDialog.razor
@@ -1,12 +1,11 @@
-@using Phantom.Controller.Services.Instances
-@using Phantom.Controller.Services.Audit
+@using Phantom.Web.Services.Instances
 @using System.ComponentModel.DataAnnotations
+@using Phantom.Common.Data.Web.Users
 @using Phantom.Common.Data.Minecraft
 @using Phantom.Common.Data.Replies
 @inherits PhantomComponent
 @inject IJSRuntime Js;
 @inject InstanceManager InstanceManager;
-@inject AuditLog AuditLog
 
 <Form Model="form" OnSubmit="StopInstance">
   <Modal Id="@ModalId" TitleText="Stop Instance">
@@ -33,13 +32,13 @@
 @code {
 
   [Parameter, EditorRequired]
-  public Guid InstanceGuid { get; set; }
+  public Guid InstanceGuid { get; init; }
 
   [Parameter, EditorRequired]
-  public string ModalId { get; set; } = string.Empty;
+  public string ModalId { get; init; } = string.Empty;
 
   [Parameter]
-  public bool Disabled { get; set; }
+  public bool Disabled { get; init; }
 
   private readonly StopInstanceFormModel form = new ();
 
@@ -51,14 +50,14 @@
   private async Task StopInstance(EditContext context) {
     await form.SubmitModel.StartSubmitting();
 
-    if (!await CheckPermission(Permission.ControlInstances)) {
+    var loggedInUserGuid = await GetUserGuid();
+    if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
       form.SubmitModel.StopSubmitting("You do not have permission to stop instances.");
       return;
     }
-    
-    var result = await InstanceManager.StopInstance(InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds));
+
+    var result = await InstanceManager.StopInstance(loggedInUserGuid.Value, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken);
     if (result.Is(StopInstanceResult.StopInitiated)) {
-      await AuditLog.AddInstanceStoppedEvent(InstanceGuid, form.StopInSeconds);
       await Js.InvokeVoidAsync("closeModal", ModalId);
       form.SubmitModel.StopSubmitting();
     }
diff --git a/Web/Phantom.Web/Shared/UserAddDialog.razor b/Web/Phantom.Web/Shared/UserAddDialog.razor
index b092253..dcc7bc5 100644
--- a/Web/Phantom.Web/Shared/UserAddDialog.razor
+++ b/Web/Phantom.Web/Shared/UserAddDialog.razor
@@ -1,17 +1,15 @@
-@using Phantom.Controller.Services.Users
-@using Phantom.Utils.Tasks
-@using Phantom.Controller.Database.Entities
-@using Phantom.Controller.Services.Audit
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Common.Data.Web.Users.CreateUserResults
+@using Phantom.Web.Services.Users
 @using System.ComponentModel.DataAnnotations
 @inherits PhantomComponent
 @inject IJSRuntime Js;
-@inject UserManager UserManager
-@inject AuditLog AuditLog
+@inject UserManager UserManager;
 
 <Form Model="form" OnSubmit="AddUser">
   <Modal Id="@ModalId" TitleText="Add User">
     <Body>
-      
+
       <div class="row">
         <div class="mb-3">
           <FormTextInput Id="account-username" Label="Username" @bind-Value="form.Username" autocomplete="off" />
@@ -23,7 +21,7 @@
           <FormTextInput Id="account-password" Label="Password" Type="FormTextInputType.Password" autocomplete="new-password" @bind-Value="form.Password" />
         </div>
       </div>
-      
+
     </Body>
     <Footer>
       <FormSubmitError />
@@ -37,9 +35,9 @@
 
   [Parameter, EditorRequired]
   public string ModalId { get; set; } = string.Empty;
-  
+
   [Parameter]
-  public EventCallback<UserEntity> UserAdded { get; set; }
+  public EventCallback<UserInfo> UserAdded { get; set; }
 
   private readonly AddUserFormModel form = new();
 
@@ -54,22 +52,26 @@
   private async Task AddUser(EditContext context) {
     await form.SubmitModel.StartSubmitting();
 
-    if (!await CheckPermission(Permission.EditUsers)) {
+    var loggedInUserGuid = await GetUserGuid();
+    if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) {
       form.SubmitModel.StopSubmitting("You do not have permission to add users.");
       return;
     }
 
-    switch (await UserManager.CreateUser(form.Username, form.Password)) {
-      case Result<UserEntity, AddUserError>.Ok ok:
-        await AuditLog.AddUserCreatedEvent(ok.Value);
-        await UserAdded.InvokeAsync(ok.Value);
+    switch (await UserManager.Create(loggedInUserGuid.Value, form.Username, form.Password, CancellationToken)) {
+      case Success success:
+        await UserAdded.InvokeAsync(success.User);
         await Js.InvokeVoidAsync("closeModal", ModalId);
         form.SubmitModel.StopSubmitting();
         break;
-        
-      case Result<UserEntity, AddUserError>.Fail fail:
+
+      case CreationFailed fail:
         form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n"));
         break;
+
+      default:
+        form.SubmitModel.StopSubmitting("Unknown error.");
+        break;
     }
   }
 
diff --git a/Web/Phantom.Web/Shared/UserDeleteDialog.razor b/Web/Phantom.Web/Shared/UserDeleteDialog.razor
index 33979c4..78440e9 100644
--- a/Web/Phantom.Web/Shared/UserDeleteDialog.razor
+++ b/Web/Phantom.Web/Shared/UserDeleteDialog.razor
@@ -1,9 +1,7 @@
-@using Phantom.Controller.Database.Entities
-@using Phantom.Controller.Services.Audit
-@using Phantom.Controller.Services.Users
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Users
 @inherits UserEditDialogBase
 @inject UserManager UserManager
-@inject AuditLog AuditLog
 
 <Modal Id="@ModalId" TitleText="Delete User">
   <Body>
@@ -19,17 +17,13 @@
 
 @code {
 
-  protected override async Task DoEdit(UserEntity user) {
-    switch (await UserManager.DeleteByGuid(user.UserGuid)) {
+  protected override async Task DoEdit(Guid loggedInUserGuid, UserInfo user) {
+    switch (await UserManager.DeleteByGuid(loggedInUserGuid, user.Guid, CancellationToken)) {
       case DeleteUserResult.Deleted:
-        await AuditLog.AddUserDeletedEvent(user);
-        await OnEditSuccess();
-        break;
-
       case DeleteUserResult.NotFound:
         await OnEditSuccess();
         break;
-        
+
       case DeleteUserResult.Failed:
         OnEditFailure("Could not delete user.");
         break;
diff --git a/Web/Phantom.Web/Shared/UserEditDialogBase.cs b/Web/Phantom.Web/Shared/UserEditDialogBase.cs
index 99320f5..481fb7d 100644
--- a/Web/Phantom.Web/Shared/UserEditDialogBase.cs
+++ b/Web/Phantom.Web/Shared/UserEditDialogBase.cs
@@ -1,9 +1,8 @@
 using Microsoft.AspNetCore.Components;
 using Microsoft.JSInterop;
-using Phantom.Controller.Database.Entities;
+using Phantom.Common.Data.Web.Users;
 using Phantom.Web.Base;
 using Phantom.Web.Components.Forms;
-using Phantom.Web.Identity.Data;
 
 namespace Phantom.Web.Shared;
 
@@ -15,14 +14,14 @@ public abstract class UserEditDialogBase : PhantomComponent {
 	public string ModalId { get; set; } = string.Empty;
 
 	[Parameter]
-	public EventCallback<UserEntity> UserModified { get; set; }
+	public EventCallback<UserInfo> UserModified { get; set; }
 
 	protected readonly FormButtonSubmit.SubmitModel SubmitModel = new();
 
-	private UserEntity? EditedUser { get; set; } = null;
+	private UserInfo? EditedUser { get; set; } = null;
 	protected string EditedUserName { get; private set; } = string.Empty;
 
-	internal async Task Show(UserEntity user) {
+	internal async Task Show(UserInfo user) {
 		EditedUser = user;
 		EditedUserName = user.Name;
 		await BeforeShown(user);
@@ -31,7 +30,7 @@ public abstract class UserEditDialogBase : PhantomComponent {
 		await Js.InvokeVoidAsync("showModal", ModalId);
 	}
 
-	protected virtual Task BeforeShown(UserEntity user) {
+	protected virtual Task BeforeShown(UserInfo user) {
 		return Task.CompletedTask;
 	}
 
@@ -42,18 +41,19 @@ public abstract class UserEditDialogBase : PhantomComponent {
 	protected async Task Submit() {
 		await SubmitModel.StartSubmitting();
 
-		if (!await CheckPermission(Permission.EditUsers)) {
+		var loggedInUserGuid = await GetUserGuid();
+		if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) {
 			SubmitModel.StopSubmitting("You do not have permission to edit users.");
 		}
 		else if (EditedUser == null) {
 			SubmitModel.StopSubmitting("Invalid user.");
 		}
 		else {
-			await DoEdit(EditedUser);
+			await DoEdit(loggedInUserGuid.Value, EditedUser);
 		}
 	}
 	
-	protected abstract Task DoEdit(UserEntity user);
+	protected abstract Task DoEdit(Guid loggedInUserGuid, UserInfo user);
 
 	protected async Task OnEditSuccess() {
 		await UserModified.InvokeAsync(EditedUser);
diff --git a/Web/Phantom.Web/Shared/UserRolesDialog.razor b/Web/Phantom.Web/Shared/UserRolesDialog.razor
index a8e1dfa..046df3a 100644
--- a/Web/Phantom.Web/Shared/UserRolesDialog.razor
+++ b/Web/Phantom.Web/Shared/UserRolesDialog.razor
@@ -1,11 +1,9 @@
-@using Phantom.Controller.Database.Entities
-@using Phantom.Controller.Services.Audit
-@using Phantom.Controller.Services.Users
+@using System.Collections.Immutable
+@using Phantom.Common.Data.Web.Users
+@using Phantom.Web.Services.Users
 @inherits UserEditDialogBase
-@inject UserManager UserManager
 @inject RoleManager RoleManager
 @inject UserRoleManager UserRoleManager
-@inject AuditLog AuditLog
 
 <Modal Id="@ModalId" TitleText="Manage User Roles">
   <Body>
@@ -27,53 +25,73 @@
 
 @code {
 
+  private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty;
   private List<RoleItem> items = new();
 
-  protected override async Task BeforeShown(UserEntity user) {
-    var userRoles = await UserRoleManager.GetUserRoleGuids(user);
-    var allRoles = await RoleManager.GetAll();
-    this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList();
+  protected override async Task BeforeShown(UserInfo user) {
+    var allRoles = await RoleManager.GetAll(CancellationToken);
+    this.allRolesByGuid = allRoles.ToImmutableDictionary(static role => role.Guid, static role => role);
+    
+    var currentRoleGuids = await UserRoleManager.GetUserRoles(user.Guid, CancellationToken);
+    this.items = allRoles.Select(role => new RoleItem(role, currentRoleGuids.Contains(role.Guid))).ToList();
   }
 
-  protected override async Task DoEdit(UserEntity user) {
-    var userRoles = await UserRoleManager.GetUserRoleGuids(user);
-    var addedToRoles = new List<string>();
-    var removedFromRoles = new List<string>();
-    var errors = new List<string>();
+  protected override async Task DoEdit(Guid loggedInUserGuid, UserInfo user) {
+    var currentRoleGuids = await UserRoleManager.GetUserRoles(user.Guid, CancellationToken);
+    var addToRoleGuids = ImmutableHashSet.CreateBuilder<Guid>();
+    var removeFromRoleGuids = ImmutableHashSet.CreateBuilder<Guid>();
 
     foreach (var item in items) {
+      var roleGuid = item.Role.Guid;
       var shouldHaveRole = item.Checked;
-      if (shouldHaveRole == userRoles.Contains(item.Role.RoleGuid)) {
+      if (shouldHaveRole == currentRoleGuids.Contains(roleGuid)) {
         continue;
       }
 
-      bool success = shouldHaveRole ? await UserRoleManager.Add(user, item.Role) : await UserRoleManager.Remove(user, item.Role);
-      if (success) {
-          var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles;
-          modifiedList.Add(item.Role.Name);
-      }
-      else if (shouldHaveRole) {
-        errors.Add("Could not add role " + item.Role.Name + " to user.");
+      if (shouldHaveRole) {
+        addToRoleGuids.Add(roleGuid);
       }
       else {
-        errors.Add("Could not remove role " + item.Role.Name + " from user.");
+        removeFromRoleGuids.Add(roleGuid);
       }
     }
+    
+    await DoChangeUserRoles(user, loggedInUserGuid, addToRoleGuids.ToImmutable(), removeFromRoleGuids.ToImmutable());
+  }
 
-    if (errors.Count == 0) {
-      await AuditLog.AddUserRolesChangedEvent(user, addedToRoles, removedFromRoles);
+  private async Task DoChangeUserRoles(UserInfo user, Guid loggedInUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) {
+    var result = await UserRoleManager.ChangeUserRoles(loggedInUserGuid, user.Guid, addToRoleGuids, removeFromRoleGuids, CancellationToken);
+    
+    var failedToAdd = addToRoleGuids.Except(result.AddedToRoleGuids);
+    var failedToRemove = removeFromRoleGuids.Except(result.RemovedFromRoleGuids);
+    
+    if (failedToAdd.IsEmpty && failedToRemove.IsEmpty) {
       await OnEditSuccess();
+      return;
     }
-    else {
-      OnEditFailure(string.Join("\n", errors));
+    
+    var errors = new List<string>();
+    
+    foreach (var roleGuid in failedToAdd) {
+        errors.Add("Could not add role: " + GetRoleName(roleGuid));
     }
+    
+    foreach (var roleGuid in failedToRemove) {
+        errors.Add("Could not remove role: " + GetRoleName(roleGuid));
+    }
+    
+    OnEditFailure(string.Join("\n", errors));
+  }
+
+  private string GetRoleName(Guid roleGuid) {
+    return allRolesByGuid.TryGetValue(roleGuid, out var role) ? role.Name : "?";
   }
 
   private sealed class RoleItem {
-    public RoleEntity Role { get; }
+    public RoleInfo Role { get; }
     public bool Checked { get; set; }
 
-    public RoleItem(RoleEntity role, bool @checked) {
+    public RoleItem(RoleInfo role, bool @checked) {
       this.Role = role;
       this.Checked = @checked;
     }
diff --git a/Web/Phantom.Web/Utils/Messages.cs b/Web/Phantom.Web/Utils/Messages.cs
index 86d0279..46a23d4 100644
--- a/Web/Phantom.Web/Utils/Messages.cs
+++ b/Web/Phantom.Web/Utils/Messages.cs
@@ -1,36 +1,43 @@
 using Phantom.Common.Data.Instance;
 using Phantom.Common.Data.Replies;
-using Phantom.Controller.Minecraft;
-using Phantom.Controller.Services.Users;
+using Phantom.Common.Data.Web.Minecraft;
+using Phantom.Common.Data.Web.Users;
 
 namespace Phantom.Web.Utils;
 
 static class Messages {
 	public static string ToSentences(this AddUserError error, string delimiter) {
 		return error switch {
-			AddUserError.NameIsEmpty         => "Name cannot be empty.",
-			AddUserError.NameIsTooLong e     => "Name cannot be longer than " + e.MaximumLength + " character(s).",
-			AddUserError.NameAlreadyExists   => "Name is already occupied.",
-			AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
-			_                                => "Unknown error."
+			Common.Data.Web.Users.AddUserErrors.NameIsInvalid e     => e.Violation.ToSentence(),
+			Common.Data.Web.Users.AddUserErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
+			Common.Data.Web.Users.AddUserErrors.NameAlreadyExists   => "Username is already occupied.",
+			_                                                       => "Unknown error."
 		};
 	}
 
 	public static string ToSentences(this SetUserPasswordError error, string delimiter) {
 		return error switch {
-			SetUserPasswordError.UserNotFound        => "User not found.",
-			SetUserPasswordError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
-			_                                        => "Unknown error."
+			Common.Data.Web.Users.SetUserPasswordErrors.UserNotFound        => "User not found.",
+			Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
+			_                                                               => "Unknown error."
+		};
+	}
+
+	public static string ToSentence(this UsernameRequirementViolation violation) {
+		return violation switch {
+			Common.Data.Web.Users.UsernameRequirementViolations.IsEmpty   => "Username must not be empty.",
+			Common.Data.Web.Users.UsernameRequirementViolations.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).",
+			_                                                             => "Unknown error."
 		};
 	}
 
 	public static string ToSentence(this PasswordRequirementViolation violation) {
 		return violation switch {
-			PasswordRequirementViolation.TooShort v              => "Password must be at least " + v.MinimumLength + " character(s) long.",
-			PasswordRequirementViolation.LowercaseLetterRequired => "Password must contain a lowercase letter.",
-			PasswordRequirementViolation.UppercaseLetterRequired => "Password must contain an uppercase letter.",
-			PasswordRequirementViolation.DigitRequired           => "Password must contain a digit.",
-			_                                                    => "Unknown error."
+			Common.Data.Web.Users.PasswordRequirementViolations.TooShort v                 => "Password must be at least " + v.MinimumLength + " character(s) long.",
+			Common.Data.Web.Users.PasswordRequirementViolations.MustContainLowercaseLetter => "Password must contain a lowercase letter.",
+			Common.Data.Web.Users.PasswordRequirementViolations.MustContainUppercaseLetter => "Password must contain an uppercase letter.",
+			Common.Data.Web.Users.PasswordRequirementViolations.MustContainDigit           => "Password must contain a digit.",
+			_                                                                              => "Unknown error."
 		};
 	}
 
diff --git a/Web/Phantom.Web/Variables.cs b/Web/Phantom.Web/Variables.cs
new file mode 100644
index 0000000..1e1366d
--- /dev/null
+++ b/Web/Phantom.Web/Variables.cs
@@ -0,0 +1,37 @@
+using Phantom.Common.Logging;
+using Phantom.Utils.Runtime;
+
+namespace Phantom.Web;
+
+sealed record Variables(
+	string ControllerHost,
+	ushort ControllerPort,
+	string? WebKeyToken,
+	string? WebKeyFilePath,
+	string WebServerHost,
+	ushort WebServerPort,
+	string WebBasePath
+) {
+	private static Variables LoadOrThrow() {
+		var (webKeyToken, webKeyFilePath) = EnvironmentVariables.GetEitherString("WEB_KEY", "WEB_KEY_FILE").Require;
+		
+		return new Variables(
+			EnvironmentVariables.GetString("CONTROLLER_HOST").Require,
+			EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9402),
+			webKeyToken,
+			webKeyFilePath,
+			EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"),
+			EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400),
+			EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/")
+		);
+	}
+
+	public static Variables LoadOrStop() {
+		try {
+			return LoadOrThrow();
+		} catch (Exception e) {
+			PhantomLogger.Root.Fatal(e.Message);
+			throw StopProcedureException.Instance;
+		}
+	}
+}
diff --git a/Web/Phantom.Web/WebKey.cs b/Web/Phantom.Web/WebKey.cs
new file mode 100644
index 0000000..9fc5cb7
--- /dev/null
+++ b/Web/Phantom.Web/WebKey.cs
@@ -0,0 +1,60 @@
+using NetMQ;
+using Phantom.Common.Data;
+using Phantom.Common.Logging;
+using Phantom.Utils.Cryptography;
+using Phantom.Utils.IO;
+using ILogger = Serilog.ILogger;
+
+namespace Phantom.Web;
+
+static class WebKey {
+	private static ILogger Logger { get; } = PhantomLogger.Create(nameof(WebKey));
+
+	public static Task<(NetMQCertificate, AuthToken)?> Load(string? webKeyToken, string? webKeyFilePath) {
+		if (webKeyFilePath != null) {
+			return LoadFromFile(webKeyFilePath);
+		}
+		else if (webKeyToken != null) {
+			return Task.FromResult(LoadFromToken(webKeyToken));
+		}
+		else {
+			throw new InvalidOperationException();
+		}
+	}
+
+	private static async Task<(NetMQCertificate, AuthToken)?> LoadFromFile(string webKeyFilePath) {
+		if (!File.Exists(webKeyFilePath)) {
+			Logger.Fatal("Missing web key file: {WebKeyFilePath}", webKeyFilePath);
+			return null;
+		}
+
+		try {
+			Files.RequireMaximumFileSize(webKeyFilePath, 64);
+			return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath));
+		} catch (IOException e) {
+			Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath);
+			Logger.Fatal(e.Message);
+			return null;
+		} catch (Exception) {
+			Logger.Fatal("File does not contain a valid web key: {WebKeyFilePath}", webKeyFilePath);
+			return null;
+		}
+	}
+
+	private static (NetMQCertificate, AuthToken)? LoadFromToken(string webKey) {
+		try {
+			return LoadFromBytes(TokenGenerator.DecodeBytes(webKey));
+		} catch (Exception) {
+			Logger.Fatal("Invalid web key: {WebKey}", webKey);
+			return null;
+		}
+	}
+
+	private static (NetMQCertificate, AuthToken)? LoadFromBytes(byte[] webKey) {
+		var (publicKey, webToken) = ConnectionCommonKey.FromBytes(webKey);
+		var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey);
+		
+		Logger.Information("Loaded web key.");
+		return (controllerCertificate, webToken);
+	}
+}
diff --git a/Web/Phantom.Web/Launcher.cs b/Web/Phantom.Web/WebLauncher.cs
similarity index 80%
rename from Web/Phantom.Web/Launcher.cs
rename to Web/Phantom.Web/WebLauncher.cs
index 58beb4c..42735b5 100644
--- a/Web/Phantom.Web/Launcher.cs
+++ b/Web/Phantom.Web/WebLauncher.cs
@@ -1,16 +1,16 @@
 using Microsoft.AspNetCore.DataProtection;
-using Phantom.Controller.Services;
+using Phantom.Common.Messages.Web;
+using Phantom.Utils.Rpc;
 using Phantom.Utils.Tasks;
 using Phantom.Web.Base;
-using Phantom.Web.Identity;
-using Phantom.Web.Identity.Interfaces;
+using Phantom.Web.Services;
 using Serilog;
 
 namespace Phantom.Web;
 
-public static class Launcher {
-	public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) {
-		var assembly = typeof(Launcher).Assembly;
+static class WebLauncher {
+	public static WebApplication CreateApplication(Configuration config, TaskManager taskManager, ServiceConfiguration serviceConfiguration, RpcConnectionToServer<IMessageToControllerListener> controllerConnection) {
+		var assembly = typeof(WebLauncher).Assembly;
 		var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
 			ApplicationName = assembly.GetName().Name,
 			ContentRootPath = Path.GetDirectoryName(assembly.Location)
@@ -25,16 +25,15 @@ public static class Launcher {
 			builder.WebHost.UseStaticWebAssets();
 		}
 
-		builder.Services.AddSingleton(serviceConfiguration);
 		builder.Services.AddSingleton(taskManager);
+		builder.Services.AddSingleton(serviceConfiguration);
+		builder.Services.AddSingleton(controllerConnection);
+		builder.Services.AddPhantomServices();
 
 		builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
 		builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
 
-		builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
-
-		builder.Services.AddPhantomIdentity(config.CancellationToken);
-		builder.Services.AddScoped<ILoginEvents, LoginEvents>();
+		builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.DataProtectionKeyFolderPath));
 
 		builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
 		builder.Services.AddServerSideBlazor();
@@ -54,7 +53,7 @@ public static class Launcher {
 
 		application.UseStaticFiles();
 		application.UseRouting();
-		application.UsePhantomIdentity();
+		application.UsePhantomServices();
 
 		application.MapControllers();
 		application.MapBlazorHub();
diff --git a/Web/Phantom.Web/_Imports.razor b/Web/Phantom.Web/_Imports.razor
index 45bb703..338c09a 100644
--- a/Web/Phantom.Web/_Imports.razor
+++ b/Web/Phantom.Web/_Imports.razor
@@ -12,9 +12,6 @@
 @using Phantom.Web.Components.Forms
 @using Phantom.Web.Components.Graphics
 @using Phantom.Web.Components.Tables
-@using Phantom.Web.Identity
-@using Phantom.Web.Identity.Authorization
-@using Phantom.Web.Identity.Data
 @using Phantom.Web.Layout
 @using Phantom.Web.Shared
 @using Phantom.Web.Utils