From dabe87da09e82e06ca4f63a34de946f0fb5aebe9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 4 Aug 2025 16:23:41 -0400 Subject: [PATCH] Merge fixes --- README.md | 1 + build/Dockerfile | 14 +- build/Version.props | 2 +- src/Tgstation.Server.Api/Models/ErrorCode.cs | 2 +- .../Rights/RightsHelper.cs | 6 + .../CreateSystemUserWithPermissionSet.graphql | 6 +- .../CreateUserFromOAuthConnection.graphql | 2 +- .../Mutations/CreateUserWithPassword.graphql | 2 +- ...WithPasswordSelectOAuthConnections.graphql | 10 +- .../GQL/Mutations/SetUserGroup.graphql | 54 +- .../Mutations/SetUserOAuthConnections.graphql | 24 +- .../Mutations/SetUserPermissionSet.graphql | 108 +-- .../UpdateUserOAuthConnections.graphql | 10 +- src/Tgstation.Server.Client/ApiClient.cs | 6 +- .../ApiClientFactory.cs | 16 +- .../IApiClientFactory.cs | 19 + .../Http/CachedResponseStream.cs | 2 +- .../Http/HttpClient.cs | 51 -- .../Http/HttpClientFactory.cs | 41 - .../Http/IAbstractHttpClientFactory.cs | 14 - .../Http/IHttpClient.cs | 33 - .../Authority/AdministrationAuthority.cs | 347 +++++---- .../Authority/Core/AuthorityBase.cs | 50 +- .../Core/AuthorityInvokerBase{TAuthority}.cs | 52 +- .../GraphQLAuthorityInvoker{TAuthority}.cs | 82 +- .../Core/IAuthorityInvoker{TAuthority}.cs | 22 +- .../Core/RequirementsGated{TResult}.cs | 163 ++++ .../Core/RestAuthorityInvoker{TAuthority}.cs | 39 +- .../Authority/IAdministrationAuthority.cs | 26 +- .../IGraphQLAuthorityInvoker{TAuthority}.cs | 38 +- .../Authority/ILoginAuthority.cs | 4 +- .../Authority/IPermissionSetAuthority.cs | 5 +- .../IRestAuthorityInvoker{TAuthority}.cs | 12 +- .../Authority/IUserAuthority.cs | 36 +- .../Authority/IUserGroupAuthority.cs | 23 +- .../Authority/LoginAuthority.cs | 68 +- .../Authority/PermissionSetAuthority.cs | 53 +- .../Authority/UserAuthority.cs | 699 ++++++++++-------- .../Authority/UserGroupAuthority.cs | 265 ++++--- .../Deployment/DeploymentLockManager.cs | 2 +- .../Components/Deployment/DmbFactory.cs | 27 +- .../Components/Deployment/IDmbFactory.cs | 5 + .../Components/Engine/ByondInstallation.cs | 13 +- .../Components/Engine/ByondInstallerBase.cs | 30 +- .../Engine/DelegatingEngineInstaller.cs | 6 +- .../Components/Engine/EngineExecutableLock.cs | 4 +- .../Engine/EngineInstallationBase.cs | 11 +- .../Components/Engine/EngineInstallerBase.cs | 22 +- .../Components/Engine/EngineManager.cs | 38 +- .../Components/Engine/IEngineInstallation.cs | 8 +- .../Engine/IEngineInstallationData.cs | 4 +- .../Components/Engine/IEngineInstaller.cs | 8 +- .../Engine/OpenDreamInstallation.cs | 23 +- .../Components/Engine/OpenDreamInstaller.cs | 52 +- .../Components/Engine/PosixByondInstaller.cs | 37 +- .../RepositoryEngineInstallationData.cs | 5 +- .../Engine/WindowsByondInstaller.cs | 43 +- .../Engine/WindowsOpenDreamInstaller.cs | 10 +- .../Engine/ZipStreamEngineInstallationData.cs | 2 +- .../Components/InstanceFactory.cs | 162 ++-- .../Session/SessionControllerFactory.cs | 24 +- .../Controllers/AdministrationController.cs | 48 +- .../Controllers/ApiController.cs | 11 +- .../Controllers/ApiRootController.cs | 2 - .../Controllers/BridgeController.cs | 2 +- .../Controllers/ChatController.cs | 2 +- .../Controllers/DreamMakerController.cs | 2 +- .../Controllers/EngineController.cs | 2 +- .../Controllers/InstanceController.cs | 2 +- .../InstancePermissionSetController.cs | 2 +- .../Controllers/JobController.cs | 4 +- .../Controllers/UserController.cs | 26 +- .../Controllers/UserGroupController.cs | 22 +- src/Tgstation.Server.Host/Core/Application.cs | 48 +- .../Extensions/ClaimsPrincipalExtensions.cs | 76 ++ .../GraphQL/AuthorizationHelper.cs | 67 ++ .../GraphQL/Interfaces/IGateway.cs | 5 +- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 4 + .../Mutations/AdministrationMutations.cs | 7 +- .../GraphQL/Mutations/UserGroupMutations.cs | 4 - .../GraphQL/Mutations/UserMutations.cs | 60 +- .../GraphQL/Subscription.cs | 3 +- .../Subscriptions/UserSubscriptions.cs | 3 - .../GraphQL/Types/GatewayInformation.cs | 103 ++- .../GraphQL/Types/RemoteGateway.cs | 20 - .../GraphQL/Types/ServerSwarm.cs | 32 +- .../GraphQL/Types/SwarmNode.cs | 5 +- .../GraphQL/Types/UpdatedUser.cs | 45 ++ .../GraphQL/Types/User.cs | 4 +- .../GraphQL/Types/UserGroup.cs | 13 +- .../GraphQL/Types/UserGroups.cs | 25 +- .../GraphQL/Types/UserName.cs | 4 +- .../GraphQL/Types/Users.cs | 9 +- .../IO/DefaultIOManager.cs | 73 +- .../IO/FileDownloader.cs | 7 +- src/Tgstation.Server.Host/IO/IIOManager.cs | 4 +- .../IO/RequestFileStreamProvider.cs | 6 +- .../IO/ResolvingIOManager.cs | 9 +- .../TransformerBase{TInput,TOutput}.cs | 2 +- .../UpdatedUserGraphQLTransformer.cs | 18 + .../Models/UpdatedUser.cs | 51 ++ .../Security/AuthenticationContextFactory.cs | 47 +- .../Security/AuthorizationHandler.cs | 229 ++++++ .../Security/AuthorizationService.cs | 55 ++ .../Security/ClaimsPrincipalAccessor.cs | 30 + .../Security/IAuthorizationService.cs | 20 + .../Security/IClaimsPrincipalAccessor.cs | 15 + .../Security/OAuth/DiscordOAuthValidator.cs | 6 +- .../Security/OAuth/GenericOAuthValidator.cs | 11 +- .../OAuth/InvisionCommunityOAuthValidator.cs | 6 +- .../Security/OAuth/KeycloakOAuthValidator.cs | 6 +- .../Security/OAuth/OAuthProviders.cs | 6 +- .../AndRightsConditional{TRights}.cs | 41 + .../FlagRightsConditional{TRights}.cs | 43 ++ .../OrRightsConditional{TRights}.cs | 41 + .../RightsConditional{TRights}.cs | 21 + .../Security/TgsGraphQLAuthorizeAttribute.cs | 137 ---- ...gsGraphQLAuthorizeAttribute{TAuthority}.cs | 41 - .../TgsRestAuthorizeAttribute{TAuthority}.cs | 42 -- .../Security/UserSessionValidRequirement.cs | 27 + src/Tgstation.Server.Host/Server.cs | 17 +- src/Tgstation.Server.Host/ServerFactory.cs | 2 +- .../Swarm/SwarmService.cs | 7 +- .../Utils/AbstractHttpClientFactory.cs | 76 -- tests/DMAPI/BasicOperation/Test.dm | 20 +- .../Models/Internal/TestEngineVersion.cs | 2 +- .../TestApiHeaders.cs | 6 +- .../TestApiClient.cs | 13 +- .../TestServerClientFactory.cs | 2 +- .../Tgstation.Server.Client.Tests.csproj | 1 + .../Extensions/TestVersionExtensions.cs | 19 + .../MockHttpMessageHandler.cs | 23 + .../Tgstation.Server.Common.Tests.csproj | 12 + .../TestServerService.cs | 4 +- .../Program.cs | 2 +- .../Chat/Providers/TestDiscordProvider.cs | 14 +- .../Chat/Providers/TestIrcProvider.cs | 14 +- .../Engine/TestOpenDreamInstaller.cs | 7 +- .../Engine/TestPosixByondInstaller.cs | 35 +- .../Events/TestEventScriptAttribute.cs | 2 +- .../Repository/TestRepositoryFactory.cs | 2 +- .../Core/TestServerUpdateInitiator.cs | 4 +- .../Database/TestDatabaseConnectionFactory.cs | 4 +- .../Database/TestDatabaseContextFactory.cs | 6 +- .../TestGeneralConfigurationExtensions.cs | 2 +- .../IO/TestConsole.cs | 4 +- .../IO/TestFileDownloader.cs | 26 +- .../IO/TestFilesystemLinkFactory.cs | 14 +- .../IO/TestIOManager.cs | 8 +- .../IO/TestPostWriteHandler.cs | 6 +- .../IO/TestRequestFileStreamProvider.cs | 78 +- .../Jobs/TestJobHandler.cs | 6 +- .../TestAndRightsConditional.cs | 38 + .../TestFlagRightsConditional.cs | 36 + .../TestOrRightsConditional.cs | 42 ++ .../Setup/TestSetupWizard.cs | 28 +- .../Swarm/TestableSwarmNode.cs | 14 +- .../System/TestPosixSignalHandler.cs | 6 +- .../TestProgram.cs | 4 +- .../TestServerFactory.cs | 12 +- .../Tgstation.Server.Host.Tests.csproj | 1 + .../Utils/GitHub/TestGitHubClientFactory.cs | 12 +- .../Utils/GitHub/TestGitHubServiceFactory.cs | 8 +- .../Utils/TestAsyncDelayer.cs | 2 +- .../TestWatchdog.cs | 4 +- .../CachingFileDownloader.cs | 26 +- .../Live/AdministrationTest.cs | 4 +- .../Tgstation.Server.Tests/Live/ApiAssert.cs | 4 +- .../Live/Instance/ChatTest.cs | 10 +- .../Live/Instance/ConfigurationTest.cs | 2 +- .../Live/Instance/DeploymentTest.cs | 8 +- .../Live/Instance/EngineTest.cs | 10 +- .../Live/Instance/InstanceTest.cs | 17 +- .../Live/Instance/RepositoryTest.cs | 6 +- .../Live/Instance/WatchdogTest.cs | 101 +-- .../Live/InstanceManagerTest.cs | 28 +- .../Live/LiveTestingServer.cs | 2 + .../Live/RateLimitRetryingApiClient.cs | 2 +- .../Live/RateLimitRetryingApiClientFactory.cs | 17 +- .../Live/RawRequestTests.cs | 8 +- .../Live/TestLiveServer.cs | 33 +- .../Tgstation.Server.Tests/Live/UsersTest.cs | 39 +- tests/Tgstation.Server.Tests/TestVersions.cs | 10 +- tests/Tgstation.Server.Tests/TestingUtils.cs | 9 +- tgstation-server.sln | 15 + 185 files changed, 3440 insertions(+), 2155 deletions(-) delete mode 100644 src/Tgstation.Server.Common/Http/HttpClient.cs delete mode 100644 src/Tgstation.Server.Common/Http/HttpClientFactory.cs delete mode 100644 src/Tgstation.Server.Common/Http/IAbstractHttpClientFactory.cs delete mode 100644 src/Tgstation.Server.Common/Http/IHttpClient.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs create mode 100644 src/Tgstation.Server.Host/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/AuthorizationHelper.cs delete mode 100644 src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/UpdatedUser.cs create mode 100644 src/Tgstation.Server.Host/Models/Transformers/UpdatedUserGraphQLTransformer.cs create mode 100644 src/Tgstation.Server.Host/Models/UpdatedUser.cs create mode 100644 src/Tgstation.Server.Host/Security/AuthorizationHandler.cs create mode 100644 src/Tgstation.Server.Host/Security/AuthorizationService.cs create mode 100644 src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs create mode 100644 src/Tgstation.Server.Host/Security/IAuthorizationService.cs create mode 100644 src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs create mode 100644 src/Tgstation.Server.Host/Security/RightsEvaluation/AndRightsConditional{TRights}.cs create mode 100644 src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs create mode 100644 src/Tgstation.Server.Host/Security/RightsEvaluation/OrRightsConditional{TRights}.cs create mode 100644 src/Tgstation.Server.Host/Security/RightsEvaluation/RightsConditional{TRights}.cs delete mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs delete mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs delete mode 100644 src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs delete mode 100644 src/Tgstation.Server.Host/Utils/AbstractHttpClientFactory.cs create mode 100644 tests/Tgstation.Server.Common.Tests/Extensions/TestVersionExtensions.cs create mode 100644 tests/Tgstation.Server.Common.Tests/MockHttpMessageHandler.cs create mode 100644 tests/Tgstation.Server.Common.Tests/Tgstation.Server.Common.Tests.csproj create mode 100644 tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestAndRightsConditional.cs create mode 100644 tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestFlagRightsConditional.cs create mode 100644 tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestOrRightsConditional.cs diff --git a/README.md b/README.md index 527ed213e5..364919b42c 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ The following dependencies are required. - aspnetcore-runtime-10.0 (See Prerequisites under the `Ubuntu/Debian Package` section) - libc6-i386 - libstdc++6:i386 +- libcurl4:i386 - gcc-multilib (Only on 64-bit systems) - gdb (for using gcore to create core dumps) diff --git a/build/Dockerfile b/build/Dockerfile index f826539df8..2bed6cf1e2 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -7,11 +7,11 @@ ARG TGS_TELEMETRY_KEY_FILE= # replace shell with bash so we can source files RUN curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.1/install.sh | sh -ENV NODE_VERSION 20.5.1 +ENV NODE_VERSION=20.5.1 -ENV NVM_DIR /root/.nvm -ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules -ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH +ENV NVM_DIR=/root/.nvm +ENV NODE_PATH=$NVM_DIR/v$NODE_VERSION/lib/node_modules +ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH RUN . $NVM_DIR/nvm.sh \ && nvm install $NODE_VERSION \ @@ -70,9 +70,9 @@ RUN dpkg --add-architecture i386 \ EXPOSE 5000 -ENV General__ValidInstancePaths__0 /tgs_instances -ENV FileLogging__Directory /tgs_logs -ENV Internal__UsingDocker true +ENV General__ValidInstancePaths__0=/tgs_instances +ENV FileLogging__Directory=/tgs_logs +ENV Internal__UsingDocker=true WORKDIR /app diff --git a/build/Version.props b/build/Version.props index 7b944755a1..9eba1b713d 100644 --- a/build/Version.props +++ b/build/Version.props @@ -5,7 +5,7 @@ 7.0.0 6.0.0 - 10.13.0 + 10.13.1 0.6.0 7.0.0 19.0.0 diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index ea2c9ebd67..b7673ff4e9 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -493,7 +493,7 @@ namespace Tgstation.Server.Api.Models /// /// Attempted to restart a stopped watchdog. /// - [Description("Cannot restart the watchdog as it is not running!")] + [Description("Cannot perform watchdog operation as it is not running!")] WatchdogNotRunning, /// diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 424d4608d5..ec1d0fad08 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -33,6 +33,12 @@ namespace Tgstation.Server.Api.Rights /// The of the given . public static Type RightToType(RightsType rightsType) => TypeMap[rightsType]; + /// + /// Iterate the of each right. + /// + /// An of each of right. + public static IEnumerable AllRightTypes() => TypeMap.Values; + /// /// Map a given to its respective . /// diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql index 3ee0b9c6bc..23ad75d65a 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql @@ -9,9 +9,11 @@ mutation CreateSystemUserWithPermissionSet($systemIdentifier: String!) { message } } - user { + updatedUser { id - name + user { + name + } } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql index dc69b9ef29..50140af490 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql @@ -7,7 +7,7 @@ mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuth message } } - user { + updatedUser { id } } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql index 8f330b0364..69e3cbe5d3 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql @@ -7,7 +7,7 @@ mutation CreateUserWithPassword($name: String!, $password: String!) { message } } - user { + updatedUser { id } } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql index 2d6b335889..86874a4834 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql @@ -1,10 +1,12 @@ mutation CreateUserWithPasswordSelectOAuthConnections($name: String!, $password: String!) { createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) { - user { + updatedUser { id - oAuthConnections { - externalUserId - provider + user { + oAuthConnections { + externalUserId + provider + } } } errors { diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql index 4299d5b537..db77e0c601 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql @@ -7,35 +7,37 @@ mutation SetUserGroup($id: ID!, $newGroupId: ID!) { message } } - user { - ownedPermissionSet { - instanceManagerRights { - canCreate - canDelete - canGrantPermissions - canList - canRead - canRelocate - canRename - canSetAutoUpdate - canSetChatBotLimit - canSetConfiguration - canSetOnline + updatedUser { + user { + ownedPermissionSet { + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnServiceConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } } - administrationRights { - canChangeVersion - canDownloadLogs - canEditOwnServiceConnections - canEditOwnPassword - canReadUsers - canRestartHost - canUploadVersion - canWriteUsers + group { + id } } - group { - id - } } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql index 81746cf0b4..34f74f64bf 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql @@ -2,17 +2,19 @@ mutation SetUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectio updateUser( input: { id: $id, newOAuthConnections: $newOAuthConnections } ) { - user { - canonicalName - createdAt - enabled - id - name - systemIdentifier - oAuthConnections { - externalUserId - provider - } + updatedUser { + user { + canonicalName + createdAt + enabled + id + name + systemIdentifier + oAuthConnections { + externalUserId + provider + } + } } errors { ... on ErrorMessageError { diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql index fa2fa558fe..ae1a58b5fc 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql @@ -7,60 +7,62 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) { message } } - user { - effectivePermissionSet { - administrationRights { - canChangeVersion - canDownloadLogs - canEditOwnServiceConnections - canEditOwnPassword - canReadUsers - canRestartHost - canUploadVersion - canWriteUsers + updatedUser { + user { + effectivePermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnServiceConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + ownedPermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnServiceConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + group { + id + } } - instanceManagerRights { - canCreate - canDelete - canGrantPermissions - canList - canRead - canRelocate - canRename - canSetAutoUpdate - canSetChatBotLimit - canSetConfiguration - canSetOnline - } - } - ownedPermissionSet { - administrationRights { - canChangeVersion - canDownloadLogs - canEditOwnServiceConnections - canEditOwnPassword - canReadUsers - canRestartHost - canUploadVersion - canWriteUsers - } - instanceManagerRights { - canCreate - canDelete - canGrantPermissions - canList - canRead - canRelocate - canRename - canSetAutoUpdate - canSetChatBotLimit - canSetConfiguration - canSetOnline - } - } - group { - id - } } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql index 8cd18d2dc5..ad0fdfdbad 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql @@ -1,10 +1,12 @@ mutation UpdateUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]) { updateUser(input: { id: $id, newOAuthConnections: $newOAuthConnections }) { - user { + updatedUser { id - oAuthConnections { - externalUserId - provider + user { + oAuthConnections { + externalUserId + provider + } } } errors { diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index 0a7214e3cd..ffa5731f35 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -69,9 +69,9 @@ namespace Tgstation.Server.Client }; /// - /// The for the . + /// The for the . /// - readonly IHttpClient httpClient; + readonly HttpClient httpClient; /// /// The s used by the . @@ -166,7 +166,7 @@ namespace Tgstation.Server.Client /// The value of . /// The value of . public ApiClient( - IHttpClient httpClient, + HttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, diff --git a/src/Tgstation.Server.Client/ApiClientFactory.cs b/src/Tgstation.Server.Client/ApiClientFactory.cs index 0429f7d649..469010c153 100644 --- a/src/Tgstation.Server.Client/ApiClientFactory.cs +++ b/src/Tgstation.Server.Client/ApiClientFactory.cs @@ -1,7 +1,7 @@ using System; +using System.Net.Http; using Tgstation.Server.Api; -using Tgstation.Server.Common.Http; namespace Tgstation.Server.Client { @@ -19,5 +19,19 @@ namespace Tgstation.Server.Client apiHeaders, tokenRefreshHeaders, authless); + + /// + public IApiClient CreateApiClient( + Uri url, + ApiHeaders apiHeaders, + ApiHeaders? tokenRefreshHeaders, + HttpMessageHandler handler, + bool disposeHandler, + bool authless) => new ApiClient( + new HttpClient(handler, disposeHandler), + url, + apiHeaders, + tokenRefreshHeaders, + authless); } } diff --git a/src/Tgstation.Server.Client/IApiClientFactory.cs b/src/Tgstation.Server.Client/IApiClientFactory.cs index e49385dcf9..f0fa49a42a 100644 --- a/src/Tgstation.Server.Client/IApiClientFactory.cs +++ b/src/Tgstation.Server.Client/IApiClientFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http; using Tgstation.Server.Api; @@ -22,5 +23,23 @@ namespace Tgstation.Server.Client ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, bool authless); + + /// + /// Create an . + /// + /// The base . + /// The for the . + /// The to use to generate a new . + /// The to use with the internal . + /// If should be disposed with the created . + /// If there should be no authentication performed. + /// A new . + public IApiClient CreateApiClient( + Uri url, + ApiHeaders apiHeaders, + ApiHeaders? tokenRefreshHeaders, + HttpMessageHandler handler, + bool disposeHandler, + bool authless); } } diff --git a/src/Tgstation.Server.Common/Http/CachedResponseStream.cs b/src/Tgstation.Server.Common/Http/CachedResponseStream.cs index eb007b54a5..14361b6ef8 100644 --- a/src/Tgstation.Server.Common/Http/CachedResponseStream.cs +++ b/src/Tgstation.Server.Common/Http/CachedResponseStream.cs @@ -36,7 +36,7 @@ namespace Tgstation.Server.Common.Http response.Content = null; try { - // don't cry about the missing CancellationToken overload: https://github.com/dotnet/runtime/issues/916 + // don't cry about the missing CancellationToken overload: https://github.com/dotnet/corefx/issues/32615#issuecomment-562083237 var responseStream = await content.ReadAsStreamAsync().ConfigureAwait(false); return new CachedResponseStream(content, responseStream); } diff --git a/src/Tgstation.Server.Common/Http/HttpClient.cs b/src/Tgstation.Server.Common/Http/HttpClient.cs deleted file mode 100644 index 692c10a304..0000000000 --- a/src/Tgstation.Server.Common/Http/HttpClient.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Common.Http -{ - /// - public sealed class HttpClient : IHttpClient - { - /// - public TimeSpan Timeout - { - get => httpClient.Timeout; - set => httpClient.Timeout = value; - } - - /// - public HttpRequestHeaders DefaultRequestHeaders => httpClient.DefaultRequestHeaders; - - /// - /// The real . - /// - readonly System.Net.Http.HttpClient httpClient; - - /// - /// Initializes a new instance of the class. - /// - /// The to wrap. - public HttpClient(System.Net.Http.HttpClient implementation) - { - httpClient = implementation ?? throw new ArgumentNullException(nameof(implementation)); - } - - /// - /// Initializes a new instance of the class. - /// - public HttpClient() - : this(new System.Net.Http.HttpClient()) - { - } - - /// - public void Dispose() => httpClient.Dispose(); - - /// - public Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) - => httpClient.SendAsync(request, completionOption, cancellationToken); - } -} diff --git a/src/Tgstation.Server.Common/Http/HttpClientFactory.cs b/src/Tgstation.Server.Common/Http/HttpClientFactory.cs deleted file mode 100644 index 93659471b5..0000000000 --- a/src/Tgstation.Server.Common/Http/HttpClientFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Net.Http.Headers; - -namespace Tgstation.Server.Common.Http -{ - /// - /// that creates s. - /// - public sealed class HttpClientFactory : IAbstractHttpClientFactory - { - /// - public IHttpClient CreateClient() - { - var client = new HttpClient(); - try - { - client.DefaultRequestHeaders.UserAgent.Add(userAgent); - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - /// - /// The used as created client's User-Agent header on request. - /// - readonly ProductInfoHeaderValue userAgent; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - public HttpClientFactory(ProductInfoHeaderValue userAgent) - { - this.userAgent = userAgent ?? throw new ArgumentNullException(nameof(userAgent)); - } - } -} diff --git a/src/Tgstation.Server.Common/Http/IAbstractHttpClientFactory.cs b/src/Tgstation.Server.Common/Http/IAbstractHttpClientFactory.cs deleted file mode 100644 index 120712f2bb..0000000000 --- a/src/Tgstation.Server.Common/Http/IAbstractHttpClientFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Tgstation.Server.Common.Http -{ - /// - /// Creates s. - /// - public interface IAbstractHttpClientFactory - { - /// - /// Create a . - /// - /// A new . - IHttpClient CreateClient(); - } -} diff --git a/src/Tgstation.Server.Common/Http/IHttpClient.cs b/src/Tgstation.Server.Common/Http/IHttpClient.cs deleted file mode 100644 index d0ade7050d..0000000000 --- a/src/Tgstation.Server.Common/Http/IHttpClient.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Common.Http -{ - /// - /// For sending HTTP requests. - /// - public interface IHttpClient : IDisposable - { - /// - /// The request timeout. - /// - TimeSpan Timeout { get; set; } - - /// - /// The used on every request. - /// - HttpRequestHeaders DefaultRequestHeaders { get; } - - /// - /// Send an HTTP request. - /// - /// The . - /// The . - /// The for the operation. - /// A resulting in the of the request. - Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs index 76c7fc937b..ca369c70dc 100644 --- a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs @@ -1,9 +1,12 @@ using System; +using System.IO; using System.Threading; using System.Threading.Tasks; +using System.Web; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Octokit; @@ -11,9 +14,11 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.IO; +using Tgstation.Server.Host.System; using Tgstation.Server.Host.Transfer; using Tgstation.Server.Host.Utils.GitHub; @@ -57,10 +62,29 @@ namespace Tgstation.Server.Host.Authority /// readonly IMemoryCache cacheService; + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// The for the . + /// + readonly IPlatformIdentifier platformIdentifier; + + /// + /// The for the . + /// + readonly IIOManager ioManager; + + /// + /// The for the . + /// + readonly IOptionsSnapshot fileLoggingConfigurationOptions; + /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . @@ -68,17 +92,23 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . public AdministrationAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IGitHubServiceFactory gitHubServiceFactory, IServerControl serverControl, IServerUpdateInitiator serverUpdateInitiator, IFileTransferTicketProvider fileTransferService, - IMemoryCache cacheService) + IMemoryCache cacheService, + IAssemblyInformationProvider assemblyInformationProvider, + IPlatformIdentifier platformIdentifier, + IIOManager ioManager, + IOptionsSnapshot fileLoggingConfigurationOptions) : base( - authenticationContext, databaseContext, logger) { @@ -87,157 +117,212 @@ namespace Tgstation.Server.Host.Authority this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.fileLoggingConfigurationOptions = fileLoggingConfigurationOptions ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } /// - public async ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken) - { - try - { - async Task CacheFactory() + public RequirementsGated> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken) + => new( + () => Flag(AdministrationRights.ChangeVersion), + async () => { - Version? greatestVersion = null; - Uri? repoUrl = null; - var scopeCancellationToken = CancellationToken.None; // DCT: None available try { - var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken); - var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken); - var releases = await gitHubService.GetTgsReleases(scopeCancellationToken); - - foreach (var kvp in releases) + async Task CacheFactory() { - var version = kvp.Key; - var release = kvp.Value; - if (version.Major > 3 // Forward/backward compatible but not before TGS4 - && (greatestVersion == null || version > greatestVersion)) - greatestVersion = version; + Version? greatestVersion = null; + Uri? repoUrl = null; + var scopeCancellationToken = CancellationToken.None; // DCT: None available + try + { + var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken); + var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken); + var releases = await gitHubService.GetTgsReleases(scopeCancellationToken); + + foreach (var kvp in releases) + { + var version = kvp.Key; + var release = kvp.Value; + if (version.Major > 3 // Forward/backward compatible but not before TGS4 + && (greatestVersion == null || version > greatestVersion)) + greatestVersion = version; + } + + repoUrl = await repositoryUrlTask; + } + catch (NotFoundException e) + { + Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); + } + + return new AdministrationResponse + { + LatestVersion = greatestVersion, + TrackedRepositoryUrl = repoUrl, + GeneratedAt = DateTimeOffset.UtcNow, + }; } - repoUrl = await repositoryUrlTask; + var ttl = TimeSpan.FromMinutes(30); + Task task; + if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) + { + using var entry = cacheService.CreateEntry(ReadCacheKey); + entry.AbsoluteExpirationRelativeToNow = ttl; + entry.Value = task = CacheFactory(); + } + else + task = (Task)rawCacheObject!; + + var result = await task.WaitAsync(cancellationToken); + return new AuthorityResponse(result); } - catch (NotFoundException e) + catch (RateLimitExceededException e) { - Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); + return RateLimit(e); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + }); + + /// + public RequirementsGated> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetVersion); + + return new( + () => + { + if (uploadZip) + return Flag(AdministrationRights.UploadVersion); + + return Flag(AdministrationRights.ChangeVersion); + }, + async () => + { + if (targetVersion.Major < 4) + return BadRequest(ErrorCode.CannotChangeServerSuite); + + if (!serverControl.WatchdogPresent) + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + + IFileUploadTicket? uploadTicket = uploadZip + ? fileTransferService.CreateUpload(FileUploadStreamKind.None) + : null; + + ServerUpdateResult updateResult; + try + { + try + { + updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken); + } + catch + { + if (uploadZip) + await uploadTicket!.DisposeAsync(); + + throw; + } + } + catch (RateLimitExceededException ex) + { + return RateLimit(ex); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); } - return new AdministrationResponse + return updateResult switch { - LatestVersion = greatestVersion, - TrackedRepositoryUrl = repoUrl, - GeneratedAt = DateTimeOffset.UtcNow, + ServerUpdateResult.Started => new AuthorityResponse(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), + ServerUpdateResult.ReleaseMissing => Gone(), + ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), + ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), + HttpFailureResponse.FailedDependency), + _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), }; - } - - var ttl = TimeSpan.FromMinutes(30); - Task task; - if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) - { - using var entry = cacheService.CreateEntry(ReadCacheKey); - entry.AbsoluteExpirationRelativeToNow = ttl; - entry.Value = task = CacheFactory(); - } - else - task = (Task)rawCacheObject!; - - var result = await task.WaitAsync(cancellationToken); - return new AuthorityResponse(result); - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }, - HttpFailureResponse.FailedDependency); - } + }); } /// - public async ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) - { - var attemptingUpload = uploadZip == true; - if (attemptingUpload) - { - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) - return Forbid(); - } - else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) - return Forbid(); - - if (targetVersion.Major < 4) - return BadRequest(ErrorCode.CannotChangeServerSuite); - - if (!serverControl.WatchdogPresent) - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), - HttpFailureResponse.UnprocessableEntity); - - IFileUploadTicket? uploadTicket = attemptingUpload - ? fileTransferService.CreateUpload(FileUploadStreamKind.None) - : null; - - ServerUpdateResult updateResult; - try - { - try + public RequirementsGated TriggerServerRestart() + => new( + () => Flag(AdministrationRights.RestartHost), + async () => { - updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken); - } - catch - { - if (attemptingUpload) - await uploadTicket!.DisposeAsync(); - - throw; - } - } - catch (RateLimitExceededException ex) - { - return RateLimit(ex); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RemoteApiError) + if (!serverControl.WatchdogPresent) { - AdditionalData = e.Message, - }, - HttpFailureResponse.FailedDependency); - } + Logger.LogDebug("Restart request failed due to lack of host watchdog!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + } - return updateResult switch - { - ServerUpdateResult.Started => new AuthorityResponse(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), - ServerUpdateResult.ReleaseMissing => Gone(), - ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), - ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), - HttpFailureResponse.FailedDependency), - _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), - }; - } + await serverControl.Restart(); + return new AuthorityResponse(); + }); /// - public async ValueTask TriggerServerRestart() + public RequirementsGated> GetLog(string path, CancellationToken cancellationToken) { - if (!serverControl.WatchdogPresent) - { - Logger.LogDebug("Restart request failed due to lack of host watchdog!"); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), - HttpFailureResponse.UnprocessableEntity); - } + ArgumentNullException.ThrowIfNull(path); + return new( + () => Flag(AdministrationRights.DownloadLogs), + async () => + { + path = HttpUtility.UrlDecode(path); - await serverControl.Restart(); - return new AuthorityResponse(); + // guard against directory navigation + var sanitizedPath = ioManager.GetFileName(path); + if (path != sanitizedPath) + return Forbid(); + + var fullPath = ioManager.ConcatPath( + fileLoggingConfigurationOptions.Value.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier), + path); + try + { + var fileTransferTicket = fileTransferService.CreateDownload( + new FileDownloadProvider( + () => null, + null, + fullPath, + true)); + + return new AuthorityResponse(new LogFileResponse + { + Name = path, + LastModified = await ioManager.GetLastModified(fullPath, cancellationToken), + FileTicket = fileTransferTicket.FileTicket, + }); + } + catch (IOException ex) + { + return Conflict(ErrorCode.IOError, ex.ToString()); + } + }); } } } diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index 0ea01a89da..e7701c903b 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -8,7 +8,7 @@ using Octokit; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.RightsEvaluation; namespace Tgstation.Server.Host.Authority.Core { @@ -17,11 +17,6 @@ namespace Tgstation.Server.Host.Authority.Core /// abstract class AuthorityBase : IAuthority { - /// - /// Gets the for the . - /// - protected IAuthenticationContext AuthenticationContext { get; } - /// /// Gets the for the . /// @@ -88,24 +83,57 @@ namespace Tgstation.Server.Host.Authority.Core /// /// The of the . /// The . + /// for the error message. /// A new, errored . - protected static AuthorityResponse Conflict(ErrorCode errorCode) + protected static AuthorityResponse Conflict(ErrorCode errorCode, string? additionalData = null) => new( - new ErrorMessageResponse(errorCode), + new ErrorMessageResponse(errorCode) + { + AdditionalData = additionalData, + }, HttpFailureResponse.Conflict); + /// + /// Helper to quickly construct a . + /// + /// The to evaluate. + /// The single bit flag of the . + /// A new . + protected static FlagRightsConditional Flag(TRights flag) + where TRights : Enum + => new(flag); + + /// + /// Helper to quickly construct an . + /// + /// The to evaluate. + /// The left hand side operand. + /// The right hand side operand. + /// A new . + protected static OrRightsConditional Or(RightsConditional lhs, RightsConditional rhs) + where TRights : Enum + => new(lhs, rhs); + + /// + /// Helper to quickly construct an . + /// + /// The to evaluate. + /// The left hand side operand. + /// The right hand side operand. + /// A new . + protected static AndRightsConditional And(RightsConditional lhs, RightsConditional rhs) + where TRights : Enum + => new(lhs, rhs); + /// /// Initializes a new instance of the class. /// - /// The value of . /// The value of . /// The value of . protected AuthorityBase( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger) { - AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); } diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs index f04293aecc..0a509ef39b 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -1,7 +1,8 @@ using System; using System.Linq; +using System.Threading.Tasks; -using Tgstation.Server.Api.Models; +using Microsoft.AspNetCore.Authorization; namespace Tgstation.Server.Host.Authority.Core { @@ -14,35 +15,60 @@ namespace Tgstation.Server.Host.Authority.Core /// protected TAuthority Authority { get; } + /// + /// The authorization service for the . + /// + readonly Security.IAuthorizationService authorizationService; + /// /// Initializes a new instance of the class. /// /// The value of . - public AuthorityInvokerBase(TAuthority authority) + /// The value of . + public AuthorityInvokerBase( + TAuthority authority, + Security.IAuthorizationService authorizationService) { Authority = authority ?? throw new ArgumentNullException(nameof(authority)); + this.authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService)); } /// - IQueryable IAuthorityInvoker.InvokeQueryable(Func> authorityInvoker) + async ValueTask?> IAuthorityInvoker.InvokeQueryable(Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(authorityInvoker); - return authorityInvoker(Authority); + + var requirementsGate = authorityInvoker(Authority); + return await ExecuteIfRequirementsSatisfied(requirementsGate); } - /// - IQueryable IAuthorityInvoker.InvokeTransformableQueryable(Func> authorityInvoker) + /// + /// Unwrap a result, returning if the requirements weren't satisfied. + /// + /// The contained by the . + /// The result. + /// A resulting in the if the requirements were met, if the requirments weren't met. + protected async ValueTask ExecuteIfRequirementsSatisfied(RequirementsGated requirementsGate) + where TResult : class { - ArgumentNullException.ThrowIfNull(authorityInvoker); + var requirements = await requirementsGate.GetRequirements(); + var authorizationResult = await authorizationService.AuthorizeAsync(requirements); - var queryable = authorityInvoker(Authority); + if (!authorizationResult.Succeeded) + { + OnRequirementsFailure(authorizationResult.Failure); + return null; + } - if (typeof(EntityId).IsAssignableFrom(typeof(TResult))) - queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning + return await requirementsGate.Execute(authorizationService); + } - var expression = new TTransformer().Expression; - return queryable - .Select(expression); + /// + /// Called to handle generic behavior when requirements evaluation fails. + /// + /// The . + protected virtual void OnRequirementsFailure(AuthorizationFailure authFailure) + { } } } diff --git a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs index a25a04e278..7d75355f57 100644 --- a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -1,6 +1,12 @@ using System; +using System.Linq; using System.Threading.Tasks; +using HotChocolate; + +using Microsoft.AspNetCore.Authorization; + +using Tgstation.Server.Api.Models; using Tgstation.Server.Host.GraphQL; namespace Tgstation.Server.Host.Authority.Core @@ -12,14 +18,17 @@ namespace Tgstation.Server.Host.Authority.Core /// /// Throws a for errored s. /// - /// The potentially errored . + /// The being checked. + /// The potentially errored or if requirements evaluation failed. /// If an error should be raised for and failures. - static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing) + /// if an wasn't thrown. + static TAuthorityResponse ThrowGraphQLErrorIfNecessary(TAuthorityResponse authorityResponse, bool errorOnMissing) + where TAuthorityResponse : AuthorityResponse { if (authorityResponse.Success || ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing)) - return; + return authorityResponse; var fallbackString = authorityResponse.FailureResponse.ToString()!; throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString); @@ -29,38 +38,43 @@ namespace Tgstation.Server.Host.Authority.Core /// Initializes a new instance of the class. /// /// The . - public GraphQLAuthorityInvoker(TAuthority authority) - : base(authority) + /// the authorization service to use. + public GraphQLAuthorityInvoker(TAuthority authority, Security.IAuthorizationService authorizationService) + : base(authority, authorizationService) { } /// - async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); ThrowGraphQLErrorIfNecessary(authorityResponse, true); } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) where TApiModel : default { ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); ThrowGraphQLErrorIfNecessary(authorityResponse, false); + return authorityResponse.Result; } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) where TApiModel : default { ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); ThrowGraphQLErrorIfNecessary(authorityResponse, false); var result = authorityResponse.Result; if (result == null) @@ -70,11 +84,49 @@ namespace Tgstation.Server.Host.Authority.Core } /// - ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) - => ((IGraphQLAuthorityInvoker)this).InvokeAllowMissing(authorityInvoker)!; + async ValueTask> IGraphQLAuthorityInvoker.InvokeTransformableQueryable( + Func>> authorityInvoker, + Func, IQueryable>? preTransformer) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var requirementsGate = authorityInvoker(Authority); + var queryable = await ExecuteIfRequirementsSatisfied(requirementsGate); + + if (preTransformer != null) + queryable = preTransformer(queryable); + + if (typeof(EntityId).IsAssignableFrom(typeof(TResult))) + queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning + + var expression = new TTransformer().Expression; + return queryable + .Select(expression); + } /// - ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) - => ((IGraphQLAuthorityInvoker)this).InvokeTransformableAllowMissing(authorityInvoker)!; + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + => await ((IGraphQLAuthorityInvoker)this).InvokeAllowMissing(authorityInvoker) + ?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!"); + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + => await ((IGraphQLAuthorityInvoker)this).InvokeTransformableAllowMissing(authorityInvoker) + ?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!"); + + /// + protected override void OnRequirementsFailure(AuthorizationFailure authFailure) + => throw authFailure.ForbiddenGraphQLException(); + + /// + /// Unwrap a result, throwing a if they weren't met. + /// + /// The contained by the . + /// The result. + /// A resulting in the if the requirements were met. + /// Throw when requirements were not met. + new async ValueTask ExecuteIfRequirementsSatisfied(RequirementsGated requirementsGate) + where TResult : class + => (await base.ExecuteIfRequirementsSatisfied(requirementsGate))!; // base class throws if requirements evaluation fails } } diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs index 9b49bd9dda..98270fcc96 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs @@ -1,7 +1,6 @@ using System; using System.Linq; - -using Tgstation.Server.Host.Models; +using System.Threading.Tasks; namespace Tgstation.Server.Host.Authority.Core { @@ -16,21 +15,8 @@ namespace Tgstation.Server.Host.Authority.Core /// Invoke a method and get the result. /// /// The returned . - /// The returning a . - /// A returned. - IQueryable InvokeQueryable(Func> authorityInvoker); - - /// - /// Invoke a method and get the transformed result. - /// - /// The returned by the . - /// The returned . - /// The for converting s to s. - /// The returning a . - /// A returned. - IQueryable InvokeTransformableQueryable(Func> authorityInvoker) - where TResult : IApiTransformable - where TApiModel : notnull - where TTransformer : ITransformer, new(); + /// The authority invocation returning a . + /// A resulting in the returned on success or if the requirements weren't satisfied. + ValueTask?> InvokeQueryable(Func>> authorityInvoker); } } diff --git a/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs new file mode 100644 index 0000000000..cf724d4605 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; + +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Evaluates a set of s to be checked before executing a response. + /// + /// The of object the response generates. + public sealed class RequirementsGated + { + /// + /// The retrieval function. is included automatically. + /// + readonly Func>> getRequirements; + + /// + /// The response generation function. + /// + readonly Func> getResponse; + + /// + /// If the should not be added. + /// + readonly bool doNotAddUserSessionValidRequirement; + + /// + /// Convert a given into a . + /// + /// The to convert. + /// A new based on . +#pragma warning disable CA1000 // Do not declare static members on generic types + public static RequirementsGated FromResult(TResult result) +#pragma warning restore CA1000 // Do not declare static members on generic types + => new( + () => (IAuthorizationRequirement?)null, + () => ValueTask.FromResult(result)); + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + public RequirementsGated( + Func> getRequirement, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirement); + ArgumentNullException.ThrowIfNull(getResponse); + getRequirements = async () => + { + var requirement = await getRequirement(); + if (requirement == null) + return Enumerable.Empty(); + + return new List + { + requirement, + }; + }; + this.getResponse = _ => getResponse(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public RequirementsGated( + Func> getRequirements, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirements); + ArgumentNullException.ThrowIfNull(getResponse); + this.getRequirements = () => ValueTask.FromResult(getRequirements()); + this.getResponse = _ => getResponse(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + /// The value of . + public RequirementsGated( + Func getRequirement, + Func> getResponse, + bool doNotAddUserSessionValidRequirement = false) + { + ArgumentNullException.ThrowIfNull(getRequirement); + ArgumentNullException.ThrowIfNull(getResponse); + getRequirements = () => + { + var requirement = getRequirement(); + if (requirement == null) + return ValueTask.FromResult(Enumerable.Empty()); + + return ValueTask.FromResult>( + new List + { + requirement, + }); + }; + + this.getResponse = _ => getResponse(); + + this.doNotAddUserSessionValidRequirement = doNotAddUserSessionValidRequirement; + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + public RequirementsGated( + Func getRequirement, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirement); + getRequirements = () => + { + var requirement = getRequirement(); + if (requirement == null) + return ValueTask.FromResult(Enumerable.Empty()); + + return ValueTask.FromResult>( + new List + { + requirement, + }); + }; + + this.getResponse = getResponse ?? throw new ArgumentNullException(nameof(getResponse)); + } + + /// + /// Evaluates the s of the request. + /// + /// A resulting in the s for the request. + public async ValueTask> GetRequirements() + { + var requirements = await getRequirements(); + if (!doNotAddUserSessionValidRequirement) + requirements = UserSessionValidRequirement.InstanceAsEnumerable.Concat(requirements); + + return requirements; + } + + /// + /// Executes the request. + /// + /// The authorization service to use. + /// A resulting in the request . + public ValueTask Execute(Security.IAuthorizationService authorizationService) + => getResponse(authorizationService); + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 52532c2f0e..f0ac777801 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Authority.Core { @@ -22,7 +23,10 @@ namespace Tgstation.Server.Host.Authority.Core /// An for the . /// The result returned in the . /// The REST API result model built from . - static IActionResult CreateSuccessfulActionResult(ApiController controller, Func resultTransformer, AuthorityResponse authorityResponse) + static IActionResult CreateSuccessfulActionResult( + ApiController controller, + Func resultTransformer, + AuthorityResponse authorityResponse) where TApiModel : notnull { if (authorityResponse.IsNoContent!.Value) @@ -44,9 +48,14 @@ namespace Tgstation.Server.Host.Authority.Core /// /// The to use. /// The . - /// An if the is not successful, otherwise. - static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse) + /// An if the is not successful, otherwise. If is returned, is not . + static IActionResult? CreateErroredActionResult( + ApiController controller, + AuthorityResponse? authorityResponse) { + if (authorityResponse == null) + return controller.Forbid(); + if (authorityResponse.Success) return null; @@ -74,47 +83,51 @@ namespace Tgstation.Server.Host.Authority.Core /// Initializes a new instance of the class. /// /// The . - public RestAuthorityInvoker(TAuthority authority) - : base(authority) + /// The to use. + public RestAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService) + : base(authority, authorizationService) { } /// - async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); } /// - async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); var erroredResult = CreateErroredActionResult(controller, authorityResponse); if (erroredResult != null) return erroredResult; - return CreateSuccessfulActionResult(controller, result => result, authorityResponse); + return CreateSuccessfulActionResult(controller, result => result, authorityResponse!); } /// - async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) + async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); var erroredResult = CreateErroredActionResult(controller, authorityResponse); if (erroredResult != null) return erroredResult; - return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse); + return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse!); } } } diff --git a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs index 7fb28925b5..6853c89ca8 100644 --- a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs @@ -1,11 +1,8 @@ using System; using System.Threading; -using System.Threading.Tasks; using Tgstation.Server.Api.Models.Response; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Authority { @@ -19,9 +16,8 @@ namespace Tgstation.Server.Host.Authority /// /// Bypass the caching that the authority performs for this request, forcing it to contact GitHub. /// The for the operation. - /// A resulting in the . - [TgsAuthorize(AdministrationRights.ChangeVersion)] - ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); + /// A . + RequirementsGated> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); /// /// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given . @@ -29,15 +25,21 @@ namespace Tgstation.Server.Host.Authority /// The TGS will switch to upon reboot. /// If a will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service. /// The for the operation. - /// A resulting in the . - [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] - ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); + /// A . + RequirementsGated> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); /// /// Triggers a restart of tgstation-server without terminating running game instances. /// - /// A resulting in the . - [TgsAuthorize(AdministrationRights.RestartHost)] - ValueTask TriggerServerRestart(); + /// A . + RequirementsGated TriggerServerRestart(); + + /// + /// Get a ticket for downloading a log file at a given . + /// + /// The relative path to the log file in the directory. + /// The for the operation. + /// A . + RequirementsGated> GetLog(string path, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs index edface219a..341b78a6cd 100644 --- a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Tgstation.Server.Host.Authority.Core; @@ -10,24 +11,25 @@ namespace Tgstation.Server.Host.Authority /// Invokes s from GraphQL endpoints. /// /// The invoked. + /// We take the approach that fields should be non-nullable if that is the case under ideal circumstances. Authorization issues should throw. public interface IGraphQLAuthorityInvoker : IAuthorityInvoker where TAuthority : IAuthority { /// /// Invoke a method with no success result. /// - /// The returning a resulting in the . + /// The resulting in the . /// A representing the running operation. - ValueTask Invoke(Func> authorityInvoker); + ValueTask Invoke(Func> authorityInvoker); /// /// Invoke a method and get the result. /// /// The . /// The resulting of the return value. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeAllowMissing(Func>> authorityInvoker) + ValueTask InvokeAllowMissing(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -37,9 +39,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the return value. /// The for converting s to s. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) + ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) where TResult : notnull, IApiTransformable where TApiModel : notnull where TTransformer : ITransformer, new(); @@ -49,9 +51,9 @@ namespace Tgstation.Server.Host.Authority /// /// The . /// The resulting of the return value. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(Func>> authorityInvoker) + ValueTask Invoke(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -61,11 +63,27 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the return value. /// The for converting s to s. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(Func>> authorityInvoker) + ValueTask InvokeTransformable(Func>> authorityInvoker) where TResult : notnull, IApiTransformable where TApiModel : notnull where TTransformer : ITransformer, new(); + + /// + /// Invoke a method and get the transformed result. + /// + /// The returned by the . + /// The returned . + /// The for converting s to s. + /// The returning a . + /// Optional transformer for the run once it has been acquired. + /// A resulting in the returned on success or if the requirements weren't satisfied. + ValueTask> InvokeTransformableQueryable( + Func>> authorityInvoker, + Func, IQueryable>? preTransformer = null) + where TResult : IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); } } diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index 0558bbfaa0..111d83271f 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -16,13 +16,13 @@ namespace Tgstation.Server.Host.Authority /// /// The for the operation. /// A resulting in a . - ValueTask> AttemptLogin(CancellationToken cancellationToken); + RequirementsGated> AttemptLogin(CancellationToken cancellationToken); /// /// Attempt to login to an OAuth service with the current OAuth credentials. /// /// The for the operation. /// A resulting in an . - ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); + RequirementsGated> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs index bb10fed383..8e4a370087 100644 --- a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs @@ -1,10 +1,8 @@ using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Models; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Authority { @@ -20,7 +18,6 @@ namespace Tgstation.Server.Host.Authority /// The of . /// The for the operation. /// A resulting in a . - [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken); + RequirementsGated> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs index 2cb05a2b9a..0e3b6f3b14 100644 --- a/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs @@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Authority /// Invoke a method with no success result. /// /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(ApiController controller, Func> authorityInvoker); + ValueTask Invoke(ApiController controller, Func> authorityInvoker); /// /// Invoke a method and get the result. @@ -30,9 +30,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the . /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(ApiController controller, Func>> authorityInvoker) + ValueTask Invoke(ApiController controller, Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -42,9 +42,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The returned REST . /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) + ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) where TResult : notnull, ILegacyApiTransformable where TApiModel : notnull; } diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index f60275aceb..2aba4ddaef 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -1,13 +1,10 @@ using System.Linq; using System.Threading; -using System.Threading.Tasks; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Models; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Authority { @@ -20,9 +17,8 @@ namespace Tgstation.Server.Host.Authority /// Gets the currently authenticated user. /// /// The for the operation. - /// A resulting in a . - [TgsAuthorize] - ValueTask> Read(CancellationToken cancellationToken); + /// A . + RequirementsGated> Read(CancellationToken cancellationToken); /// /// Gets the with a given . @@ -31,33 +27,31 @@ namespace Tgstation.Server.Host.Authority /// If related entities should be loaded. /// If the may be returned. /// The for the operation. - /// A resulting in a . - [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); + /// A . + RequirementsGated> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); /// /// Gets the s for the with a given . /// /// The of the . /// The for the operation. - /// A resulting in an of . - ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); + /// A of . + RequirementsGated> OAuthConnections(long userId, CancellationToken cancellationToken); /// /// Gets the s for the with a given . /// /// The of the . /// The for the operation. - /// A resulting in an of . - ValueTask> OidcConnections(long userId, CancellationToken cancellationToken); + /// A of . + RequirementsGated> OidcConnections(long userId, CancellationToken cancellationToken); /// /// Gets all registered s. /// /// If related entities should be loaded. - /// A of s. - [TgsAuthorize(AdministrationRights.ReadUsers)] - IQueryable Queryable(bool includeJoins); + /// A of s. + RequirementsGated> Queryable(bool includeJoins); /// /// Creates a . @@ -65,9 +59,8 @@ namespace Tgstation.Server.Host.Authority /// The . /// If a zero-length indicates and OAuth only user. /// The for the operation. - /// A resulting in am for the created . - [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Create( + /// A for the created . + RequirementsGated> Create( UserCreateRequest createRequest, bool? needZeroLengthPasswordWithOAuthConnections, CancellationToken cancellationToken); @@ -77,8 +70,7 @@ namespace Tgstation.Server.Host.Authority /// /// The . /// The for the operation. - /// A resulting in am for the created . - [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)] - ValueTask> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); + /// A for the created . + RequirementsGated> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs index 28113b95f5..c63c417ab1 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -17,8 +17,9 @@ namespace Tgstation.Server.Host.Authority /// /// Gets the current . /// + /// The for the operation. /// A resulting in a . - ValueTask> Read(); + RequirementsGated> Read(CancellationToken cancellationToken); /// /// Gets the with a given . @@ -26,17 +27,17 @@ namespace Tgstation.Server.Host.Authority /// The of the . /// If related entities should be loaded. /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + RequirementsGated> GetId(long id, bool includeJoins, CancellationToken cancellationToken); /// /// Gets all registered s. /// /// If related entities should be loaded. - /// A of s. + /// A of s. [TgsAuthorize(AdministrationRights.ReadUsers)] - IQueryable Queryable(bool includeJoins); + RequirementsGated> Queryable(bool includeJoins); /// /// Create a . @@ -44,9 +45,9 @@ namespace Tgstation.Server.Host.Authority /// The created 's . /// The created 's . /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); + RequirementsGated> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); /// /// Updates a . @@ -55,17 +56,17 @@ namespace Tgstation.Server.Host.Authority /// The optional new for the . /// The optional new for the . /// The for the operation. - /// A resulting in a . + /// A resulting in a . [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken); + RequirementsGated> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken); /// /// Deletes an empty . /// /// The of the to delete. /// The for the operation. - /// A representing the running operation. + /// A representing the running operation. [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask DeleteEmpty(long id, CancellationToken cancellationToken); + RequirementsGated DeleteEmpty(long id, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index ab8af50f91..f1b2b8b47c 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -103,7 +104,6 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . @@ -115,7 +115,6 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The containing the value of . public LoginAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IApiHeadersProvider apiHeadersProvider, @@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Authority ISessionInvalidationTracker sessionInvalidationTracker, IOptions securityConfigurationOptions) : base( - authenticationContext, databaseContext, logger) { @@ -142,7 +140,45 @@ namespace Tgstation.Server.Host.Authority } /// - public async ValueTask> AttemptLogin(CancellationToken cancellationToken) + public RequirementsGated> AttemptLogin(CancellationToken cancellationToken) + => new( + () => null, + () => AttemptLoginImpl(cancellationToken), + true); + + /// + public RequirementsGated> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) + => new( + () => (IAuthorizationRequirement?)null, + async () => + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + var oAuthProvider = headers.OAuthProvider; + if (!oAuthProvider.HasValue) + return BadRequest(ErrorCode.BadHeaders); + + var (errorResponse, oAuthResult) = await TryOAuthenticate(headers, oAuthProvider.Value, false, cancellationToken); + if (errorResponse != null) + return errorResponse; + + Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); + + return new( + new OAuthGatewayLoginResult + { + AccessCode = oAuthResult!.Value.AccessCode, + }); + }); + + /// + /// Login process. + /// + /// The for the operation. + /// A resulting in the for the . + private async ValueTask> AttemptLoginImpl(CancellationToken cancellationToken) { // password and oauth logins disabled if (securityConfiguration.OidcStrictMode) @@ -278,30 +314,6 @@ namespace Tgstation.Server.Host.Authority } } - /// - public async ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) - { - var headers = apiHeadersProvider.ApiHeaders; - if (headers == null) - return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); - - var oAuthProvider = headers.OAuthProvider; - if (!oAuthProvider.HasValue) - return BadRequest(ErrorCode.BadHeaders); - - var (errorResponse, oAuthResult) = await TryOAuthenticate(headers, oAuthProvider.Value, false, cancellationToken); - if (errorResponse != null) - return errorResponse; - - Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); - - return new AuthorityResponse( - new OAuthGatewayLoginResult - { - AccessCode = oAuthResult!.Value.AccessCode, - }); - } - /// /// Add a given to the . /// diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs index a252aae1cc..541ee88e1f 100644 --- a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -25,6 +26,11 @@ namespace Tgstation.Server.Host.Authority /// readonly IPermissionSetsDataLoader permissionSetsDataLoader; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// Implements . /// @@ -85,34 +91,59 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . + /// The value of . public PermissionSetAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, - IPermissionSetsDataLoader permissionSetsDataLoader) + IPermissionSetsDataLoader permissionSetsDataLoader, + IClaimsPrincipalAccessor claimsPrincipalAccessor) : base( - authenticationContext, databaseContext, logger) { this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader)); + this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor)); } /// - public async ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) + public RequirementsGated> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) { - if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + var permissionSetTask = permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken); + return new( + async () => + { + var userId = claimsPrincipalAccessor.User.GetTgsUserId(); - var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken); - if (permissionSet == null) - return NotFound(); + var groupIdQuery = DatabaseContext + .Users + .AsQueryable() + .Where(user => user.Id == userId) + .Select(user => user.GroupId); - return new AuthorityResponse(permissionSet); + var permissionSetId = await DatabaseContext + .PermissionSets + .Where(permissionSet => permissionSet.UserId == userId + || groupIdQuery.Contains(permissionSet.GroupId)) + .Select(permissionSet => permissionSet.Id!.Value) + .FirstAsync(cancellationToken); + + if (permissionSetId == id) + return null; + + return Flag(AdministrationRights.ReadUsers); + }, + async () => + { + var permissionSet = await permissionSetTask; + + if (permissionSet == null) + return NotFound(); + + return new AuthorityResponse(permissionSet); + }); } } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index e9577dbcfd..8bee7fd0d0 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -9,6 +9,7 @@ using GreenDonut; using HotChocolate.Subscriptions; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -22,9 +23,11 @@ using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.RightsEvaluation; namespace Tgstation.Server.Host.Authority { @@ -71,6 +74,11 @@ namespace Tgstation.Server.Host.Authority /// readonly ITopicEventSender topicEventSender; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// The of for the . /// @@ -164,22 +172,21 @@ namespace Tgstation.Server.Host.Authority /// The to check. /// If this is a new . /// if is valid, an errored otherwise. - static AuthorityResponse? CheckValidName(UserUpdateRequest model, bool newUser) + static AuthorityResponse? CheckValidName(UserUpdateRequest model, bool newUser) { var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null; if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name))) - return BadRequest(ErrorCode.UserMissingName); + return BadRequest(ErrorCode.UserMissingName); model.Name = model.Name?.Trim(); if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) - return BadRequest(ErrorCode.UserColonInName); + return BadRequest(ErrorCode.UserColonInName); return null; } /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . @@ -190,10 +197,10 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . /// The value of . public UserAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IUsersDataLoader usersDataLoader, @@ -204,10 +211,10 @@ namespace Tgstation.Server.Host.Authority ICryptographySuite cryptographySuite, ISessionInvalidationTracker sessionInvalidationTracker, ITopicEventSender topicEventSender, + IClaimsPrincipalAccessor claimsPrincipalAccessor, IOptionsSnapshot generalConfigurationOptions, IOptions securityConfigurationOptions) : base( - authenticationContext, databaseContext, logger) { @@ -219,6 +226,7 @@ namespace Tgstation.Server.Host.Authority this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker)); this.topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender)); + this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor)); this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); } @@ -233,11 +241,11 @@ namespace Tgstation.Server.Host.Authority static bool BadCreateRequestChecks( UserCreateRequest createRequest, bool? needZeroLengthPasswordWithOAuthConnections, - [NotNullWhen(true)] out AuthorityResponse? failResponse) + [NotNullWhen(true)] out AuthorityResponse? failResponse) { if (createRequest.OAuthConnections?.Any(x => x == null) == true) { - failResponse = BadRequest(ErrorCode.ModelValidationFailure); + failResponse = BadRequest(ErrorCode.ModelValidationFailure); return true; } @@ -247,7 +255,7 @@ namespace Tgstation.Server.Host.Authority if ((hasNonNullPassword && hasNonNullSystemIdentifier) || (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections)) { - failResponse = BadRequest(ErrorCode.UserMismatchPasswordSid); + failResponse = BadRequest(ErrorCode.UserMismatchPasswordSid); return true; } @@ -261,20 +269,20 @@ namespace Tgstation.Server.Host.Authority if (createRequest.OAuthConnections.Count == 0) { - failResponse = BadRequest(ErrorCode.ModelValidationFailure); + failResponse = BadRequest(ErrorCode.ModelValidationFailure); return true; } } else if (hasZeroLengthPassword) { - failResponse = BadRequest(ErrorCode.ModelValidationFailure); + failResponse = BadRequest(ErrorCode.ModelValidationFailure); return true; } } if (createRequest.Group != null && createRequest.PermissionSet != null) { - failResponse = BadRequest(ErrorCode.UserGroupAndPermissionSet); + failResponse = BadRequest(ErrorCode.UserGroupAndPermissionSet); return true; } @@ -284,7 +292,7 @@ namespace Tgstation.Server.Host.Authority if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null)) { - failResponse = BadRequest(ErrorCode.UserMismatchNameSid); + failResponse = BadRequest(ErrorCode.UserMismatchNameSid); return true; } @@ -293,15 +301,350 @@ namespace Tgstation.Server.Host.Authority } /// - public ValueTask> Read(CancellationToken cancellationToken) - => ValueTask.FromResult(new AuthorityResponse(AuthenticationContext.User)); + public RequirementsGated> Read(CancellationToken cancellationToken) + => new( + () => Enumerable.Empty(), + () => GetIdImpl(claimsPrincipalAccessor.User.RequireTgsUserId(), true, false, cancellationToken)); /// - public async ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) - { - if (id != AuthenticationContext.User.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + public RequirementsGated> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) + => new( + () => + { + if (id != claimsPrincipalAccessor.User.GetTgsUserId()) + return Enumerable.Empty(); + return new List + { + Flag(AdministrationRights.ReadUsers), + }; + }, + () => GetIdImpl(id, includeJoins, allowSystemUser, cancellationToken)); + + /// + public RequirementsGated> Queryable(bool includeJoins) + => new( + () => Flag(AdministrationRights.ReadUsers), + () => ValueTask.FromResult(Queryable(includeJoins, false))); + + /// + public RequirementsGated> OAuthConnections(long userId, CancellationToken cancellationToken) + => new( + () => claimsPrincipalAccessor.User.GetTgsUserId() != userId + ? Flag(AdministrationRights.ReadUsers) + : null, + async () => new AuthorityResponse( + await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken))); + + /// + public RequirementsGated> OidcConnections(long userId, CancellationToken cancellationToken) + => new( + () => claimsPrincipalAccessor.User.GetTgsUserId() != userId + ? Flag(AdministrationRights.ReadUsers) + : null, + async () => new AuthorityResponse( + await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken))); + + /// +#pragma warning disable CA1506 // TODO: Decomplexify + public RequirementsGated> Create( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken) +#pragma warning restore CA1506 + => new( + () => Flag(AdministrationRights.WriteUsers), + async authorizationService => + { + ArgumentNullException.ThrowIfNull(createRequest); + + if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse)) + return failResponse; + + var totalUsers = await DatabaseContext + .Users + .AsQueryable() + .CountAsync(cancellationToken); + if (totalUsers >= generalConfigurationOptions.Value.UserLimit) + return Conflict(ErrorCode.UserLimitReached); + + var dbUser = await CreateNewUserFromModel( + createRequest, + cancellationToken); + if (dbUser == null) + return Gone(); + + if (createRequest.SystemIdentifier != null) + try + { + using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken); + if (sysIdentity == null) + return Gone(); + dbUser.Name = sysIdentity.Username; + dbUser.SystemIdentifier = sysIdentity.Uid; + } + catch (NotImplementedException ex) + { + Logger.LogTrace(ex, "System identities not implemented!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), + HttpFailureResponse.NotImplemented); + } + else + { + var hasZeroLengthPassword = createRequest.Password?.Length == 0; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; + + // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set + if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) + { + var result = TrySetPassword(dbUser, createRequest.Password!, true); + if (result != null) + return result; + } + } + + dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!); + + DatabaseContext.Users.Add(dbUser); + + await DatabaseContext.Save(cancellationToken); + + Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); + + var responseTask = UpdatedUserResponse(authorizationService, dbUser, HttpSuccessResponse.Created); + + await SendUserUpdatedTopics(dbUser); + + return await responseTask; + }); + + /// +#pragma warning disable CA1502 +#pragma warning disable CA1506 // TODO: Decomplexify + public RequirementsGated> Update(UserUpdateRequest model, CancellationToken cancellationToken) +#pragma warning restore CA1502 +#pragma warning restore CA1506 + => new( + () => + { + RightsConditional? conditional = null; + + // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) + if (model.OidcConnections != null || model.OAuthConnections != null) + conditional = Flag(AdministrationRights.EditOwnServiceConnections); + + if (model.Password != null && model.Id == claimsPrincipalAccessor.User.GetTgsUserId()) + { + var newFlag = Flag(AdministrationRights.EditOwnPassword); + if (conditional != null) + conditional = And(conditional, newFlag); + else + conditional = newFlag; + } + + if (conditional != null) + conditional = Or(conditional, Flag(AdministrationRights.WriteUsers)); + else if (model.Enabled.HasValue + || model.Group != null + || model.Name != null + || model.PermissionSet != null) + conditional = Flag(AdministrationRights.WriteUsers); + + return conditional; + }, + async authorizationService => + { + ArgumentNullException.ThrowIfNull(model); + + if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) + return BadRequest(ErrorCode.ModelValidationFailure); + + if (model.Group != null && model.PermissionSet != null) + return BadRequest(ErrorCode.UserGroupAndPermissionSet); + + var userQuery = DatabaseContext + .Users + .AsQueryable() + .Where(x => x.Id == model.Id) + .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.OidcConnections) + .Include(x => x.Group!) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + + var originalUser = await userQuery; + + if (originalUser == default) + return NotFound(); + + if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); + + var originalUserHasSid = originalUser.SystemIdentifier != null; + var invalidateSessions = false; + if (originalUserHasSid && originalUser.PasswordHash != null) + { + // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 + Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id); + originalUser.PasswordHash = null; + + invalidateSessions = true; + } + + if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) + return BadRequest(ErrorCode.UserSidChange); + + if (model.Password != null) + { + if (originalUserHasSid) + return BadRequest(ErrorCode.UserMismatchPasswordSid); + + var result = TrySetPassword(originalUser, model.Password, false); + if (result != null) + return result; + + invalidateSessions = true; + } + + if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName) + return BadRequest(ErrorCode.UserNameChange); + + if (model.OAuthConnections != null + && (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count + || !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId)))) + { + if (securityConfigurationOptions.Value.OidcStrictMode) + return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + + if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); + + if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); + + DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections); + originalUser.OAuthConnections.Clear(); + + foreach (var updatedConnection in model.OAuthConnections) + originalUser.OAuthConnections.Add(new Models.OAuthConnection + { + Provider = updatedConnection.Provider, + ExternalUserId = updatedConnection.ExternalUserId, + }); + } + + if (model.OidcConnections != null + && (model.OidcConnections.Count != originalUser.OidcConnections!.Count + || !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId)))) + { + if (securityConfigurationOptions.Value.OidcStrictMode) + return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + + if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); + + if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); + + DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections); + originalUser.OidcConnections.Clear(); + foreach (var updatedConnection in model.OidcConnections) + originalUser.OidcConnections.Add(new Models.OidcConnection + { + SchemeKey = updatedConnection.SchemeKey, + ExternalUserId = updatedConnection.ExternalUserId, + }); + } + + if (model.Group != null) + { + if (securityConfigurationOptions.Value.OidcStrictMode) + return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + + originalUser.Group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + + if (originalUser.Group == default) + return Gone(); + + DatabaseContext.Groups.Attach(originalUser.Group); + if (originalUser.PermissionSet != null) + { + Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id); + DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); + originalUser.PermissionSet = null; + } + } + else if (model.PermissionSet != null) + { + if (securityConfigurationOptions.Value.OidcStrictMode) + return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + + if (originalUser.PermissionSet == null) + { + Logger.LogTrace("Creating new permission set..."); + originalUser.PermissionSet = new Models.PermissionSet(); + } + + originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None; + originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None; + + originalUser.Group = null; + originalUser.GroupId = null; + } + + var fail = CheckValidName(model, false); + if (fail != null) + return fail; + + originalUser.Name = model.Name ?? originalUser.Name; + + if (model.Enabled.HasValue) + { + if (securityConfigurationOptions.Value.OidcStrictMode) + return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + + invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value; + originalUser.Enabled = model.Enabled.Value; + } + + if (invalidateSessions) + sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser); + + await DatabaseContext.Save(cancellationToken); + + Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); + + var responseTask = UpdatedUserResponse(authorizationService, originalUser, HttpSuccessResponse.Ok); + + ValueTask sessionInvalidationTask; + if (invalidateSessions) + sessionInvalidationTask = permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); + else + sessionInvalidationTask = ValueTask.CompletedTask; + + await ValueTaskExtensions.WhenAll(SendUserUpdatedTopics(originalUser), sessionInvalidationTask); + + return await responseTask; + }); + + /// + /// Implementation of retrieving a by ID. + /// + /// The of the user to retrieve. + /// If related entities should be loaded. + /// If the may be returned. + /// The for the operation. + /// A . + async ValueTask> GetIdImpl(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) + { User? user; if (includeJoins) { @@ -323,286 +666,29 @@ namespace Tgstation.Server.Host.Authority return new AuthorityResponse(user); } - /// - public IQueryable Queryable(bool includeJoins) - => Queryable(includeJoins, false); - - /// - public async ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken) - => new AuthorityResponse( - await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); - - /// - public async ValueTask> OidcConnections(long userId, CancellationToken cancellationToken) - => new AuthorityResponse( - await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); - - /// - public async ValueTask> Create( - UserCreateRequest createRequest, - bool? needZeroLengthPasswordWithOAuthConnections, - CancellationToken cancellationToken) + /// + /// Create the for an . + /// + /// The authorization service to use. + /// The for the result. + /// The to use. + /// A resulting in the . + async ValueTask> UpdatedUserResponse( + Security.IAuthorizationService authorizationService, + User user, + HttpSuccessResponse successResponse) { - ArgumentNullException.ThrowIfNull(createRequest); - - if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse)) - return failResponse; - - var totalUsers = await DatabaseContext - .Users - .AsQueryable() - .CountAsync(cancellationToken); - if (totalUsers >= generalConfigurationOptions.Value.UserLimit) - return Conflict(ErrorCode.UserLimitReached); - - var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken); - if (dbUser == null) - return Gone(); - - if (createRequest.SystemIdentifier != null) - try - { - using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken); - if (sysIdentity == null) - return Gone(); - dbUser.Name = sysIdentity.Username; - dbUser.SystemIdentifier = sysIdentity.Uid; - } - catch (NotImplementedException ex) - { - Logger.LogTrace(ex, "System identities not implemented!"); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), - HttpFailureResponse.NotImplemented); - } - else - { - var hasZeroLengthPassword = createRequest.Password?.Length == 0; - var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; - - // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set - if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) - { - var result = TrySetPassword(dbUser, createRequest.Password!, true); - if (result != null) - return result; - } - } - - dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!); - - DatabaseContext.Users.Add(dbUser); - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); - - await SendUserUpdatedTopics(dbUser); - - return new AuthorityResponse(dbUser, HttpSuccessResponse.Created); - } - - /// -#pragma warning disable CA1502 -#pragma warning disable CA1506 // TODO: Decomplexify - public async ValueTask> Update(UserUpdateRequest model, CancellationToken cancellationToken) -#pragma warning restore CA1502 -#pragma warning restore CA1506 - { - ArgumentNullException.ThrowIfNull(model); - - if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(ErrorCode.ModelValidationFailure); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(ErrorCode.UserGroupAndPermissionSet); - - var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); - var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); - var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword); - var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnServiceConnections); - - var originalUser = !canEditAllUsers - ? AuthenticationContext.User - : await DatabaseContext - .Users - .AsQueryable() - .Where(x => x.Id == model.Id) - .Include(x => x.CreatedBy) - .Include(x => x.OAuthConnections) - .Include(x => x.OidcConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser == default) - return NotFound(); - - if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) - return Forbid(); - - // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) - if ((!canEditAllUsers - && (model.Id != originalUser.Id - || model.Enabled.HasValue - || model.Group != null - || model.PermissionSet != null - || model.Name != null)) - || (!passwordEdit && model.Password != null) - || (!oAuthEdit && model.OAuthConnections != null)) - return Forbid(); - - var originalUserHasSid = originalUser.SystemIdentifier != null; - var invalidateSessions = false; - if (originalUserHasSid && originalUser.PasswordHash != null) - { - // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 - Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id); - originalUser.PasswordHash = null; - - invalidateSessions = true; - } - - if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) - return BadRequest(ErrorCode.UserSidChange); - - if (model.Password != null) - { - if (originalUserHasSid) - return BadRequest(ErrorCode.UserMismatchPasswordSid); - - var result = TrySetPassword(originalUser, model.Password, false); - if (result != null) - return result; - - invalidateSessions = true; - } - - if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName) - return BadRequest(ErrorCode.UserNameChange); - - if (model.OAuthConnections != null - && (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count - || !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId)))) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); - - if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); - - if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); - - DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections); - originalUser.OAuthConnections.Clear(); - - foreach (var updatedConnection in model.OAuthConnections) - originalUser.OAuthConnections.Add(new Models.OAuthConnection - { - Provider = updatedConnection.Provider, - ExternalUserId = updatedConnection.ExternalUserId, - }); - } - - if (model.OidcConnections != null - && (model.OidcConnections.Count != originalUser.OidcConnections!.Count - || !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId)))) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); - - if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); - - if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); - - DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections); - originalUser.OidcConnections.Clear(); - foreach (var updatedConnection in model.OidcConnections) - originalUser.OidcConnections.Add(new Models.OidcConnection - { - SchemeKey = updatedConnection.SchemeKey, - ExternalUserId = updatedConnection.ExternalUserId, - }); - } - - if (model.Group != null) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); - - originalUser.Group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Group.Id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser.Group == default) - return Gone(); - - DatabaseContext.Groups.Attach(originalUser.Group); - if (originalUser.PermissionSet != null) - { - Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id); - DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); - originalUser.PermissionSet = null; - } - } - else if (model.PermissionSet != null) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); - - if (originalUser.PermissionSet == null) - { - Logger.LogTrace("Creating new permission set..."); - originalUser.PermissionSet = new Models.PermissionSet(); - } - - originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None; - originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None; - - originalUser.Group = null; - originalUser.GroupId = null; - } - - var fail = CheckValidName(model, false); - if (fail != null) - return fail; - - originalUser.Name = model.Name ?? originalUser.Name; - - if (model.Enabled.HasValue) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); - - invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value; - originalUser.Enabled = model.Enabled.Value; - } - - if (invalidateSessions) - sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser); - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); - - if (invalidateSessions) - await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); - - await SendUserUpdatedTopics(originalUser); - // return id only if not a self update and cannot read users - var canReadBack = AuthenticationContext.User.Id == originalUser.Id - || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers); - return canReadBack - ? new AuthorityResponse(originalUser) - : new AuthorityResponse(); + var userId = user.Require(u => u.Id); + var canReadBack = claimsPrincipalAccessor.User.GetTgsUserId() == userId + || (await authorizationService.AuthorizeAsync( + [Flag(AdministrationRights.ReadUsers)])).Succeeded; + + return new AuthorityResponse( + canReadBack + ? new UpdatedUser(user) + : new UpdatedUser(userId), + successResponse); } /// @@ -672,10 +758,23 @@ namespace Tgstation.Server.Host.Authority InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, }; + /* + var currentUser = new User + { + Id = claimsPrincipalAccessor.User.GetTgsUserId(), + }; + */ + + // Temporary workaround while we work to remove authentication context + var currentUser = DatabaseContext.Users.Local.First( + user => user.Id == claimsPrincipalAccessor.User.GetTgsUserId()); + + DatabaseContext.Users.Attach(currentUser); + return new User { CreatedAt = DateTimeOffset.UtcNow, - CreatedBy = AuthenticationContext.User, + CreatedBy = currentUser, Enabled = model.Enabled ?? false, PermissionSet = permissionSet, Group = group, @@ -709,11 +808,11 @@ namespace Tgstation.Server.Host.Authority /// The new password. /// If this is for a new . /// on success, an errored if is too short. - AuthorityResponse? TrySetPassword(User dbUser, string newPassword, bool newUser) + AuthorityResponse? TrySetPassword(User dbUser, string newPassword, bool newUser) { newPassword ??= String.Empty; if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength) - return new AuthorityResponse( + return new AuthorityResponse( new ErrorMessageResponse(ErrorCode.UserPasswordLength) { AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}", diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index 582e9d4324..080406c97a 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using GreenDonut; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,6 +17,7 @@ using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -29,6 +31,11 @@ namespace Tgstation.Server.Host.Authority /// readonly IUserGroupsDataLoader userGroupsDataLoader; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// The of the . /// @@ -60,58 +67,181 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. + /// The value of . /// The value of . /// The value of . public UserGroupAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IUserGroupsDataLoader userGroupsDataLoader, + IClaimsPrincipalAccessor claimsPrincipalAccessor, IOptionsSnapshot generalConfigurationOptions) : base( - authenticationContext, databaseContext, logger) { this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); + this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor)); this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// - public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + public RequirementsGated> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + => new( + () => + { + if (id != claimsPrincipalAccessor.User.GetTgsUserId()) + return Flag(AdministrationRights.ReadUsers); + + return null; + }, + async () => + { + UserGroup? userGroup; + if (includeJoins) + userGroup = await QueryableImpl(true) + .Where(x => x.Id == id) + .FirstOrDefaultAsync(cancellationToken); + else + userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); + + if (userGroup == null) + return Gone(); + + return new AuthorityResponse(userGroup); + }); + + /// + public RequirementsGated> Read(CancellationToken cancellationToken) + => new( + () => (IAuthorizationRequirement?)null, + async () => + { + var userId = claimsPrincipalAccessor.User.GetTgsUserId(); + var group = await DatabaseContext + .Users + .AsQueryable() + .Where(user => user.Id == userId) + .Select(user => user.Group) + .FirstOrDefaultAsync(cancellationToken); + + if (group == null) + return Gone(); + + return new AuthorityResponse(group); + }); + + /// + public RequirementsGated> Queryable(bool includeJoins) + => new( + () => Flag(AdministrationRights.ReadUsers), + () => ValueTask.FromResult(QueryableImpl(includeJoins))); + + /// + public RequirementsGated> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken) { - if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + ArgumentNullException.ThrowIfNull(name); + return new( + () => Flag(AdministrationRights.WriteUsers), + async () => + { + var totalGroups = await DatabaseContext + .Groups + .AsQueryable() + .CountAsync(cancellationToken); + if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit) + return Conflict(ErrorCode.UserGroupLimitReached); - UserGroup? userGroup; - if (includeJoins) - userGroup = await Queryable(true) - .Where(x => x.Id == id) - .FirstOrDefaultAsync(cancellationToken); - else - userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); + var modelPermissionSet = new Models.PermissionSet + { + AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, + }; - if (userGroup == null) - return Gone(); + var dbGroup = new UserGroup + { + Name = name, + PermissionSet = modelPermissionSet, + }; - return new AuthorityResponse(userGroup); + DatabaseContext.Groups.Add(dbGroup); + await DatabaseContext.Save(cancellationToken); + Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); + + return new AuthorityResponse( + dbGroup, + HttpSuccessResponse.Created); + }); } /// - public ValueTask> Read() - { - var group = AuthenticationContext.User!.Group; - if (group == null) - return ValueTask.FromResult(Gone()); + public RequirementsGated> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken) + => new( + () => Flag(AdministrationRights.WriteUsers), + async () => + { + var currentGroup = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); - return ValueTask.FromResult(new AuthorityResponse(group)); - } + if (currentGroup == default) + return Gone(); + + if (newPermissionSet != null) + { + currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; + currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; + } + + currentGroup.Name = newName ?? currentGroup.Name; + + await DatabaseContext.Save(cancellationToken); + + return new AuthorityResponse(currentGroup); + }); /// - public IQueryable Queryable(bool includeJoins) + public RequirementsGated DeleteEmpty(long id, CancellationToken cancellationToken) + => new( + () => Flag(AdministrationRights.WriteUsers), + async () => + { + var numDeleted = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id && x.Users!.Count == 0) + .ExecuteDeleteAsync(cancellationToken); + + if (numDeleted > 0) + return new(); + + // find out how we failed + var groupExists = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .AnyAsync(cancellationToken); + + return new( + groupExists + ? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty) + : new ErrorMessageResponse(), + groupExists + ? HttpFailureResponse.Conflict + : HttpFailureResponse.Gone); + }); + + /// + /// Get the s. + /// + /// If and should be included. + /// An of s. + IQueryable QueryableImpl(bool includeJoins) { var queryable = DatabaseContext .Groups @@ -124,92 +254,5 @@ namespace Tgstation.Server.Host.Authority return queryable; } - - /// - public async ValueTask> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(name); - - var totalGroups = await DatabaseContext - .Groups - .AsQueryable() - .CountAsync(cancellationToken); - if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit) - return Conflict(ErrorCode.UserGroupLimitReached); - - var modelPermissionSet = new Models.PermissionSet - { - AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - var dbGroup = new UserGroup - { - Name = name, - PermissionSet = modelPermissionSet, - }; - - DatabaseContext.Groups.Add(dbGroup); - await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); - - return new AuthorityResponse( - dbGroup, - HttpSuccessResponse.Created); - } - - /// - public async ValueTask> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken) - { - var currentGroup = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (currentGroup == default) - return Gone(); - - if (newPermissionSet != null) - { - currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; - currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; - } - - currentGroup.Name = newName ?? currentGroup.Name; - - await DatabaseContext.Save(cancellationToken); - - return new AuthorityResponse(currentGroup); - } - - /// - public async ValueTask DeleteEmpty(long id, CancellationToken cancellationToken) - { - var numDeleted = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id && x.Users!.Count == 0) - .ExecuteDeleteAsync(cancellationToken); - - if (numDeleted > 0) - return new(); - - // find out how we failed - var groupExists = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .AnyAsync(cancellationToken); - - return new( - groupExists - ? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty) - : new ErrorMessageResponse(), - groupExists - ? HttpFailureResponse.Conflict - : HttpFailureResponse.Gone); - } } } diff --git a/src/Tgstation.Server.Host/Components/Deployment/DeploymentLockManager.cs b/src/Tgstation.Server.Host/Components/Deployment/DeploymentLockManager.cs index 46efbfdbc9..568fca8022 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DeploymentLockManager.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DeploymentLockManager.cs @@ -63,7 +63,7 @@ namespace Tgstation.Server.Host.Components.Deployment /// /// The to get a description of. /// A verbose description of . - static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}"; + static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.EngineVersion} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}"; /// /// Initializes a new instance of the class. diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index a9cd217f18..c6ff9808a3 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Deployment readonly CancellationTokenSource cleanupCts; /// - /// The for . + /// The for . /// readonly CancellationTokenSource lockLogCts; @@ -224,7 +224,7 @@ namespace Tgstation.Server.Host.Components.Deployment } // we dont do CleanUnusedCompileJobs here because the watchdog may have plans for them yet - cleanupTask = Task.WhenAll(cleanupTask, LogLockStates()); + cleanupTask = Task.WhenAll(cleanupTask, LogLockStatesLoop()); } /// @@ -331,6 +331,18 @@ namespace Tgstation.Server.Host.Components.Deployment return provider.CompileJob; } + /// + public void LogLockStates() + { + var builder = new StringBuilder(); + + lock (jobLockManagers) + foreach (var lockManager in jobLockManagers.Values) + lockManager.LogLockStats(builder); + + logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder); + } + /// /// Gets a and potentially the for a given . /// @@ -517,7 +529,7 @@ namespace Tgstation.Server.Host.Components.Deployment /// Lock all s states. /// /// A representing the running operation. - async Task LogLockStates() + async Task LogLockStatesLoop() { logger.LogTrace("Entering lock logging loop"); CancellationToken cancellationToken = lockLogCts.Token; @@ -525,14 +537,7 @@ namespace Tgstation.Server.Host.Components.Deployment while (!cancellationToken.IsCancellationRequested) try { - var builder = new StringBuilder(); - - lock (jobLockManagers) - foreach (var lockManager in jobLockManagers.Values) - lockManager.LogLockStats(builder); - - logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder); - + LogLockStates(); await asyncDelayer.Delay(TimeSpan.FromMinutes(10), cancellationToken); } catch (OperationCanceledException ex) diff --git a/src/Tgstation.Server.Host/Components/Deployment/IDmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/IDmbFactory.cs index c737b8b358..7c8046a222 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/IDmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/IDmbFactory.cs @@ -49,5 +49,10 @@ namespace Tgstation.Server.Host.Components.Deployment /// The for the operation. /// A representing the running operation. ValueTask CleanUnusedCompileJobs(CancellationToken cancellationToken); + + /// + /// Log the states of all active s. + /// + void LogLockStates(); } } diff --git a/src/Tgstation.Server.Host/Components/Engine/ByondInstallation.cs b/src/Tgstation.Server.Host/Components/Engine/ByondInstallation.cs index 8ee095babc..524151784f 100644 --- a/src/Tgstation.Server.Host/Components/Engine/ByondInstallation.cs +++ b/src/Tgstation.Server.Host/Components/Engine/ByondInstallation.cs @@ -113,19 +113,24 @@ namespace Tgstation.Server.Host.Components.Engine /// public override string FormatServerArguments( IDmbProvider dmbProvider, - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters, + string accessIdentifier, string? logFilePath) { ArgumentNullException.ThrowIfNull(dmbProvider); - ArgumentNullException.ThrowIfNull(parameters); ArgumentNullException.ThrowIfNull(launchParameters); + ArgumentNullException.ThrowIfNull(accessIdentifier); - var parametersString = EncodeParameters(parameters, launchParameters); + var encodedParameters = EncodeParameters(parameters, launchParameters); + var parametersString = !String.IsNullOrEmpty(encodedParameters) + ? $" -params \"{encodedParameters}\"" + : String.Empty; + // important to run on all ports to allow port changing var arguments = String.Format( CultureInfo.InvariantCulture, - "\"{0}\" -port {1} -ports 1-65535 {2}-close -verbose -{3} -{4}{5}{6}{7} -params \"{8}\"", + "\"{0}\" -port {1} -ports 1-65535 {2}-close -verbose -{3} -{4}{5}{6}{7}{8}", dmbProvider.DmbName, launchParameters.Port!.Value, launchParameters.AllowWebClient!.Value diff --git a/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs b/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs index 41f657f10f..668a487692 100644 --- a/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs +++ b/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs @@ -141,34 +141,38 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) + public sealed override ValueTask GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) { CheckVersionValidity(version); var installationIOManager = IOManager.CreateResolverForSubdirectory(path); var supportsMapThreads = version.Version >= MapThreadsVersion; + var dreamDaemonName = GetDreamDaemonName( + version.Version!, + out var supportsCli); + var dreamDaemonPath = installationIOManager.ResolvePath( + installationIOManager.ConcatPath( + ByondBinPath, + dreamDaemonName)); + var dreamMakerPath = installationIOManager.ResolvePath( + installationIOManager.ConcatPath( + ByondBinPath, + DreamMakerName)); + return ValueTask.FromResult( new ByondInstallation( installationIOManager, installationTask, version, - installationIOManager.ResolvePath( - installationIOManager.ConcatPath( - ByondBinPath, - GetDreamDaemonName( - version.Version!, - out var supportsCli))), - installationIOManager.ResolvePath( - installationIOManager.ConcatPath( - ByondBinPath, - DreamMakerName)), + dreamDaemonPath, + dreamMakerPath, supportsCli, supportsMapThreads)); } /// - public override async Task CleanCache(CancellationToken cancellationToken) + public sealed override async Task CleanCache(CancellationToken cancellationToken) { try { @@ -214,7 +218,7 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override async ValueTask DownloadVersion(EngineVersion version, JobProgressReporter progressReporter, CancellationToken cancellationToken) + public sealed override async ValueTask DownloadVersion(EngineVersion version, JobProgressReporter progressReporter, CancellationToken cancellationToken) { CheckVersionValidity(version); diff --git a/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs index 5b3cc20c36..6d690bb50b 100644 --- a/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs @@ -33,15 +33,15 @@ namespace Tgstation.Server.Host.Components.Engine => Task.WhenAll(delegatedInstallers.Values.Select(installer => installer.CleanCache(cancellationToken))); /// - public ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) - => DelegateCall(version, installer => installer.CreateInstallation(version, path, installationTask, cancellationToken)); + public ValueTask GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) + => DelegateCall(version, installer => installer.GetInstallation(version, path, installationTask, cancellationToken)); /// public ValueTask DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken) => DelegateCall(version, installer => installer.DownloadVersion(version, jobProgressReporter, cancellationToken)); /// - public ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + public ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) => DelegateCall(version, installer => installer.Install(version, path, deploymentPipelineProcesses, cancellationToken)); /// diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineExecutableLock.cs b/src/Tgstation.Server.Host/Components/Engine/EngineExecutableLock.cs index 3589d6c4af..1c6e69d95b 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineExecutableLock.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineExecutableLock.cs @@ -45,13 +45,15 @@ namespace Tgstation.Server.Host.Components.Engine /// public string FormatServerArguments( IDmbProvider dmbProvider, - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters, + string accessIdentifier, string? logFilePath) => Instance.FormatServerArguments( dmbProvider, parameters, launchParameters, + accessIdentifier, logFilePath); /// diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineInstallationBase.cs b/src/Tgstation.Server.Host/Components/Engine/EngineInstallationBase.cs index f31364786c..641cc0315a 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineInstallationBase.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineInstallationBase.cs @@ -57,13 +57,15 @@ namespace Tgstation.Server.Host.Components.Engine /// The active . /// The formatted parameters . protected static string EncodeParameters( - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters) { - var parametersString = String.Join('&', parameters.Select(kvp => $"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}")); + var parametersString = parameters != null + ? $"{String.Join('&', parameters.Select(kvp => $"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}"))}&" + : String.Empty; if (!String.IsNullOrEmpty(launchParameters.AdditionalParameters)) - parametersString = $"{parametersString}&{launchParameters.AdditionalParameters}"; + parametersString += launchParameters.AdditionalParameters; return parametersString; } @@ -83,8 +85,9 @@ namespace Tgstation.Server.Host.Components.Engine /// public abstract string FormatServerArguments( IDmbProvider dmbProvider, - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters, + string accessIdentifier, string? logFilePath); /// diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs b/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs index 30329edd34..f7fbf7d072 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs @@ -40,13 +40,21 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public abstract ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); + public abstract ValueTask GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); /// public abstract Task CleanCache(CancellationToken cancellationToken); /// - public abstract ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken); + public async ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + { + CheckVersionValidity(version); + ArgumentNullException.ThrowIfNull(path); + + await InstallImpl(version, path, deploymentPipelineProcesses, cancellationToken); + + return await GetInstallation(version, path, Task.CompletedTask, cancellationToken); + } /// public abstract ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken); @@ -67,5 +75,15 @@ namespace Tgstation.Server.Host.Components.Engine if (version.Engine!.Value != TargetEngineType) throw new InvalidOperationException($"Non-{TargetEngineType} engine specified: {version.Engine.Value}"); } + + /// + /// Does actions necessary to get an extracted installation working. + /// + /// The being installed. + /// The path to the installation. + /// If the operation should consider processes it launches to be part of the deployment pipeline. + /// The for the operation. + /// A representing the running operation. + protected abstract ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs b/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs index 8f8b199746..4aa8b0a695 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -59,6 +60,11 @@ namespace Tgstation.Server.Host.Components.Engine /// readonly IEventConsumer eventConsumer; + /// + /// The for the . + /// + readonly IDmbFactory dmbFactory; + /// /// The for the . /// @@ -100,12 +106,19 @@ namespace Tgstation.Server.Host.Components.Engine /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . - public EngineManager(IIOManager ioManager, IEngineInstaller engineInstaller, IEventConsumer eventConsumer, ILogger logger) + public EngineManager( + IIOManager ioManager, + IEngineInstaller engineInstaller, + IEventConsumer eventConsumer, + IDmbFactory dmbFactory, + ILogger logger) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.engineInstaller = engineInstaller ?? throw new ArgumentNullException(nameof(engineInstaller)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); + this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); installedVersions = new Dictionary>(); @@ -228,6 +241,9 @@ namespace Tgstation.Server.Host.Components.Engine activeVersionUpdate = activeVersionChanged.Task; logger.LogTrace("Waiting for container.OnZeroReferences or switch of active version..."); + if (!containerTask.IsCompleted) + dmbFactory.LogLockStates(); + await Task.WhenAny( containerTask, activeVersionUpdate) @@ -337,7 +353,7 @@ namespace Tgstation.Server.Host.Components.Engine try { - var installation = await engineInstaller.CreateInstallation(version, path, Task.CompletedTask, cancellationToken); + var installation = await engineInstaller.GetInstallation(version, path, Task.CompletedTask, cancellationToken); AddInstallationContainer(installation); logger.LogDebug("Added detected BYOND version {versionKey}...", version); } @@ -428,7 +444,7 @@ namespace Tgstation.Server.Host.Components.Engine } } - var potentialInstallation = await engineInstaller.CreateInstallation( + var potentialInstallation = await engineInstaller.GetInstallation( version, ioManager.ResolvePath(version.ToString()), ourTcs.Task, @@ -598,7 +614,21 @@ namespace Tgstation.Server.Host.Components.Engine remainingReporter.StageName = "Running installation actions"; - await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken); + var installation = await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken); + + // some minor validation + var serverInstallTask = ioManager.FileExists(installation.ServerExePath, cancellationToken); + if (!await ioManager.FileExists(installation.CompilerExePath, cancellationToken)) + { + logger.LogError("Compiler executable does not exist after engine installation!"); + throw new JobException(ErrorCode.EngineDownloadFail); + } + + if (!await serverInstallTask) + { + logger.LogError("Server executable does not exist after engine installation!"); + throw new JobException(ErrorCode.EngineDownloadFail); + } remainingReporter.ReportProgress(0.9); remainingReporter.StageName = "Writing version file"; diff --git a/src/Tgstation.Server.Host/Components/Engine/IEngineInstallation.cs b/src/Tgstation.Server.Host/Components/Engine/IEngineInstallation.cs index 1d467a2881..d5cb2136ae 100644 --- a/src/Tgstation.Server.Host/Components/Engine/IEngineInstallation.cs +++ b/src/Tgstation.Server.Host/Components/Engine/IEngineInstallation.cs @@ -60,14 +60,16 @@ namespace Tgstation.Server.Host.Components.Engine /// Return the command line arguments for launching with given . /// /// The . - /// The map of parameter s as a . MUST include . Should NOT include the of . + /// The optional map of parameter s as a . MUST include . Should NOT include the of . /// The . + /// The secure used to authenticate communication with the game server. /// The full path to the log file, if any. /// The formatted arguments . string FormatServerArguments( IDmbProvider dmbProvider, - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters, + string accessIdentifier, string? logFilePath); /// @@ -83,7 +85,7 @@ namespace Tgstation.Server.Host.Components.Engine /// /// The to write to. /// The to be terminated. - /// The of the session. + /// The secure used to authenticate communication with the game server. /// The port the server is running on. /// The for the operation. /// A representing the running operation. diff --git a/src/Tgstation.Server.Host/Components/Engine/IEngineInstallationData.cs b/src/Tgstation.Server.Host/Components/Engine/IEngineInstallationData.cs index b3d8458973..0194c10d52 100644 --- a/src/Tgstation.Server.Host/Components/Engine/IEngineInstallationData.cs +++ b/src/Tgstation.Server.Host/Components/Engine/IEngineInstallationData.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Host.Components.Engine /// /// The full path to extract to. /// The for the operation. - /// A representing the running operation. - Task ExtractToPath(string path, CancellationToken cancellationToken); + /// A representing the running operation. + ValueTask ExtractToPath(string path, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs index 1e943f9759..50e54dad60 100644 --- a/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs @@ -18,8 +18,8 @@ namespace Tgstation.Server.Host.Components.Engine /// The path to the installation. /// The representing the installation process for the installation. /// The for the operation. - /// A resulting in the . - ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); + /// A resulting in a new for the given . + ValueTask GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); /// /// Download a given engine . @@ -37,8 +37,8 @@ namespace Tgstation.Server.Host.Components.Engine /// The path to the installation. /// If the operation should consider processes it launches to be part of the deployment pipeline. /// The for the operation. - /// A representing the running operation. - ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken); + /// A resulting in the new . + ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken); /// /// Does actions necessary to get upgrade a version installed by a previous version of TGS. diff --git a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs index b8f5e7f877..686b0994d2 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs @@ -12,9 +12,7 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components.Deployment; -using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Utils; @@ -56,9 +54,9 @@ namespace Tgstation.Server.Host.Components.Engine readonly IAsyncDelayer asyncDelayer; /// - /// The for the . + /// The for the . /// - readonly IAbstractHttpClientFactory httpClientFactory; + readonly IHttpClientFactory httpClientFactory; /// /// Path to the Robust.Server.dll. @@ -84,7 +82,7 @@ namespace Tgstation.Server.Host.Components.Engine public OpenDreamInstallation( IIOManager installationIOManager, IAsyncDelayer asyncDelayer, - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, string dotnetPath, string serverDllPath, string compilerDllPath, @@ -110,20 +108,21 @@ namespace Tgstation.Server.Host.Components.Engine /// public override string FormatServerArguments( IDmbProvider dmbProvider, - IReadOnlyDictionary parameters, + IReadOnlyDictionary? parameters, DreamDaemonLaunchParameters launchParameters, + string accessIdentifier, string? logFilePath) { ArgumentNullException.ThrowIfNull(dmbProvider); - ArgumentNullException.ThrowIfNull(parameters); ArgumentNullException.ThrowIfNull(launchParameters); + ArgumentNullException.ThrowIfNull(accessIdentifier); - if (!parameters.TryGetValue(DMApiConstants.ParamAccessIdentifier, out var accessIdentifier)) - throw new ArgumentException($"parameters must have \"{DMApiConstants.ParamAccessIdentifier}\" set!", nameof(parameters)); + var encodedParameters = EncodeParameters(parameters, launchParameters); + var parametersString = !String.IsNullOrEmpty(encodedParameters) + ? $" --cvar opendream.world_params=\"{encodedParameters}\"" + : String.Empty; - var parametersString = EncodeParameters(parameters, launchParameters); - - var arguments = $"{serverDllPath} --cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value} --cvar opendream.world_params=\"{parametersString}\" --cvar opendream.json_path=\"./{dmbProvider.DmbName}\""; + var arguments = $"{serverDllPath} --cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value}{parametersString} --cvar opendream.json_path=\"./{dmbProvider.DmbName}\""; return arguments; } diff --git a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs index 87ad2cd721..e7a53e3cd8 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -8,7 +9,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Extensions; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; @@ -77,9 +77,9 @@ namespace Tgstation.Server.Host.Components.Engine readonly IAsyncDelayer asyncDelayer; /// - /// The for the . + /// The for the . /// - readonly IAbstractHttpClientFactory httpClientFactory; + readonly IHttpClientFactory httpClientFactory; /// /// Initializes a new instance of the class. @@ -100,7 +100,7 @@ namespace Tgstation.Server.Host.Components.Engine IProcessExecutor processExecutor, IRepositoryManager repositoryManager, IAsyncDelayer asyncDelayer, - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IOptions generalConfigurationOptions, IOptions sessionConfigurationOptions) : base(ioManager, logger) @@ -115,10 +115,10 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override Task CleanCache(CancellationToken cancellationToken) => Task.CompletedTask; + public sealed override Task CleanCache(CancellationToken cancellationToken) => Task.CompletedTask; /// - public override async ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) + public sealed override async ValueTask GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) { CheckVersionValidity(version); GetExecutablePaths(path, out var serverExePath, out var compilerExePath); @@ -137,7 +137,7 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override async ValueTask DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken) + public sealed override async ValueTask DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken) { CheckVersionValidity(version); ArgumentNullException.ThrowIfNull(jobProgressReporter); @@ -216,10 +216,26 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override async ValueTask Install(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken) { CheckVersionValidity(version); - ArgumentNullException.ThrowIfNull(installPath); + ArgumentNullException.ThrowIfNull(path); + return ValueTask.CompletedTask; + } + + /// + public override ValueTask TrustDmbPath(EngineVersion engineVersion, string fullDmbPath, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(engineVersion); + ArgumentNullException.ThrowIfNull(fullDmbPath); + + Logger.LogTrace("TrustDmbPath is a no-op: {path}", fullDmbPath); + return ValueTask.CompletedTask; + } + + /// + protected override async ValueTask InstallImpl(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + { var sourcePath = IOManager.ConcatPath(installPath, InstallationSourceSubDirectory); if (!await IOManager.DirectoryExists(sourcePath, cancellationToken)) @@ -335,24 +351,6 @@ namespace Tgstation.Server.Host.Components.Engine await IOManager.DeleteDirectory(sourcePath, cancellationToken); } - /// - public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken) - { - CheckVersionValidity(version); - ArgumentNullException.ThrowIfNull(path); - return ValueTask.CompletedTask; - } - - /// - public override ValueTask TrustDmbPath(EngineVersion engineVersion, string fullDmbPath, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(engineVersion); - ArgumentNullException.ThrowIfNull(fullDmbPath); - - Logger.LogTrace("TrustDmbPath is a no-op: {path}", fullDmbPath); - return ValueTask.CompletedTask; - } - /// /// Perform an operation on a very long path. /// diff --git a/src/Tgstation.Server.Host/Components/Engine/PosixByondInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/PosixByondInstaller.cs index 7e21f6a378..ee98a5acd9 100644 --- a/src/Tgstation.Server.Host/Components/Engine/PosixByondInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/PosixByondInstaller.cs @@ -75,11 +75,27 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken) { CheckVersionValidity(version); ArgumentNullException.ThrowIfNull(path); + return ValueTask.CompletedTask; + } + + /// + public override ValueTask TrustDmbPath(EngineVersion version, string fullDmbPath, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(fullDmbPath); + + Logger.LogTrace("No need to trust .dmb path \"{path}\" on POSIX", fullDmbPath); + return ValueTask.CompletedTask; + } + + /// + protected override ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + { // write the scripts for running the ting // need to add $ORIGIN to LD_LIBRARY_PATH const string StandardScript = "#!/bin/sh\nexport LD_LIBRARY_PATH=\"\\$ORIGIN:$LD_LIBRARY_PATH\"\nBASEDIR=$(dirname \"$0\")\nexec \"$BASEDIR/{0}\" \"$@\"\n"; @@ -114,25 +130,6 @@ namespace Tgstation.Server.Host.Components.Engine return task; } - /// - public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken) - { - CheckVersionValidity(version); - ArgumentNullException.ThrowIfNull(path); - - return ValueTask.CompletedTask; - } - - /// - public override ValueTask TrustDmbPath(EngineVersion version, string fullDmbPath, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(version); - ArgumentNullException.ThrowIfNull(fullDmbPath); - - Logger.LogTrace("No need to trust .dmb path \"{path}\" on POSIX", fullDmbPath); - return ValueTask.CompletedTask; - } - /// protected override string GetDreamDaemonName(Version byondVersion, out bool supportsCli) { diff --git a/src/Tgstation.Server.Host/Components/Engine/RepositoryEngineInstallationData.cs b/src/Tgstation.Server.Host/Components/Engine/RepositoryEngineInstallationData.cs index 88fbb77911..8b89075d4e 100644 --- a/src/Tgstation.Server.Host/Components/Engine/RepositoryEngineInstallationData.cs +++ b/src/Tgstation.Server.Host/Components/Engine/RepositoryEngineInstallationData.cs @@ -48,12 +48,11 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public Task ExtractToPath(string path, CancellationToken cancellationToken) + public ValueTask ExtractToPath(string path, CancellationToken cancellationToken) => repository.CopyTo( ioManager.ConcatPath( path, targetSubDirectory), - cancellationToken) - .AsTask(); + cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Engine/WindowsByondInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/WindowsByondInstaller.cs index 010ec9ef03..0c0f345dfd 100644 --- a/src/Tgstation.Server.Host/Components/Engine/WindowsByondInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/WindowsByondInstaller.cs @@ -130,29 +130,6 @@ namespace Tgstation.Server.Host.Components.Engine /// public void Dispose() => semaphore.Dispose(); - /// - public override ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) - { - CheckVersionValidity(version); - ArgumentNullException.ThrowIfNull(path); - - var noPromptTrustedTask = SetNoPromptTrusted(path, cancellationToken); - var installDirectXTask = InstallDirectX(path, cancellationToken); - var tasks = new List(3) - { - noPromptTrustedTask, - installDirectXTask, - }; - - if (!GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException) - { - var firewallTask = AddDreamDaemonToFirewall(version, path, deploymentPipelineProcesses, cancellationToken); - tasks.Add(firewallTask); - } - - return ValueTaskExtensions.WhenAll(tasks); - } - /// public override async ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken) { @@ -215,6 +192,26 @@ namespace Tgstation.Server.Host.Components.Engine } } + /// + protected override ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + { + var noPromptTrustedTask = SetNoPromptTrusted(path, cancellationToken); + var installDirectXTask = InstallDirectX(path, cancellationToken); + var tasks = new List(3) + { + noPromptTrustedTask, + installDirectXTask, + }; + + if (!GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException) + { + var firewallTask = AddDreamDaemonToFirewall(version, path, deploymentPipelineProcesses, cancellationToken); + tasks.Add(firewallTask); + } + + return ValueTaskExtensions.WhenAll(tasks); + } + /// protected override string GetDreamDaemonName(Version byondVersion, out bool supportsCli) { diff --git a/src/Tgstation.Server.Host/Components/Engine/WindowsOpenDreamInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/WindowsOpenDreamInstaller.cs index 1cc8da52c5..adcf6ae443 100644 --- a/src/Tgstation.Server.Host/Components/Engine/WindowsOpenDreamInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/WindowsOpenDreamInstaller.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -7,7 +8,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Extensions; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; @@ -36,7 +36,7 @@ namespace Tgstation.Server.Host.Components.Engine /// The for the . /// The for the . /// The for the . - /// The for the . + /// The for the . /// The of for the . /// The of for the . /// The value of . @@ -47,7 +47,7 @@ namespace Tgstation.Server.Host.Components.Engine IProcessExecutor processExecutor, IRepositoryManager repositoryManager, IAsyncDelayer asyncDelayer, - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IOptions generalConfigurationOptions, IOptions sessionConfigurationOptions, IFilesystemLinkFactory linkFactory) @@ -66,9 +66,9 @@ namespace Tgstation.Server.Host.Components.Engine } /// - public override ValueTask Install(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken) + protected override ValueTask InstallImpl(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken) { - var installTask = base.Install( + var installTask = base.InstallImpl( version, installPath, deploymentPipelineProcesses, diff --git a/src/Tgstation.Server.Host/Components/Engine/ZipStreamEngineInstallationData.cs b/src/Tgstation.Server.Host/Components/Engine/ZipStreamEngineInstallationData.cs index 116689f8de..195c2b3b04 100644 --- a/src/Tgstation.Server.Host/Components/Engine/ZipStreamEngineInstallationData.cs +++ b/src/Tgstation.Server.Host/Components/Engine/ZipStreamEngineInstallationData.cs @@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.Engine public ValueTask DisposeAsync() => zipStream.DisposeAsync(); /// - public Task ExtractToPath(string path, CancellationToken cancellationToken) + public ValueTask ExtractToPath(string path, CancellationToken cancellationToken) => ioManager.ZipToDirectory(path, zipStream, cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/InstanceFactory.cs b/src/Tgstation.Server.Host/Components/InstanceFactory.cs index 3eb27823e7..2a12c9e409 100644 --- a/src/Tgstation.Server.Host/Components/InstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/InstanceFactory.cs @@ -290,12 +290,6 @@ namespace Tgstation.Server.Host.Components var repoManager = repositoryManagerFactory.CreateRepositoryManager(repoIoManager, eventConsumer); try { - var engineManager = new EngineManager( - byondIOManager, - engineInstaller, - eventConsumer, - loggerFactory.CreateLogger()); - var dmbFactory = new DmbFactory( databaseContextFactory, gameIoManager, @@ -306,100 +300,114 @@ namespace Tgstation.Server.Host.Components metadata); try { - var commandFactory = new CommandFactory(assemblyInformationProvider, engineManager, repoManager, databaseContextFactory, dmbFactory, metadata); - - var chatManager = chatFactory.CreateChatManager(commandFactory, metadata.ChatSettings); + var engineManager = new EngineManager( + byondIOManager, + engineInstaller, + eventConsumer, + dmbFactory, + loggerFactory.CreateLogger()); try { - var reattachInfoHandler = new SessionPersistor( - databaseContextFactory, - dmbFactory, - processExecutor, - loggerFactory.CreateLogger(), - metadata); + var commandFactory = new CommandFactory(assemblyInformationProvider, engineManager, repoManager, databaseContextFactory, dmbFactory, metadata); - var sessionControllerFactory = new SessionControllerFactory( - processExecutor, - engineManager, - topicClientFactory, - cryptographySuite, - assemblyInformationProvider, - gameIoManager, - diagnosticsIOManager, - chatManager, - networkPromptReaper, - platformIdentifier, - bridgeRegistrar, - eventConsumer, - asyncDelayer, - dotnetDumpService, - metricFactory, - loggerFactory, - loggerFactory.CreateLogger(), - sessionConfiguration, - metadata); - - var watchdog = watchdogFactory.CreateWatchdog( - chatManager, - dmbFactory, - reattachInfoHandler, - sessionControllerFactory, - gameIoManager, - diagnosticsIOManager, - configuration, // watchdog doesn't need itself as an event consumer - remoteDeploymentManagerFactory, - metricFactory, - metadata, - metadata.DreamDaemonSettings!); + var chatManager = chatFactory.CreateChatManager(commandFactory, metadata.ChatSettings); try { - eventConsumer.SetWatchdog(watchdog); - commandFactory.SetWatchdog(watchdog); - - Instance? instance = null; - var dreamMaker = new DreamMaker( - engineManager, - gameIoManager, - configuration, - sessionControllerFactory, - eventConsumer, - chatManager, - processExecutor, + var reattachInfoHandler = new SessionPersistor( + databaseContextFactory, dmbFactory, - repoManager, - remoteDeploymentManagerFactory, + processExecutor, + loggerFactory.CreateLogger(), + metadata); + + var sessionControllerFactory = new SessionControllerFactory( + processExecutor, + engineManager, + topicClientFactory, + cryptographySuite, + assemblyInformationProvider, + gameIoManager, + diagnosticsIOManager, + chatManager, + networkPromptReaper, + platformIdentifier, + bridgeRegistrar, + eventConsumer, asyncDelayer, + dotnetDumpService, metricFactory, - loggerFactory.CreateLogger(), + loggerFactory, + loggerFactory.CreateLogger(), sessionConfiguration, metadata); - instance = new Instance( - metadata, - repoManager, - engineManager, - dreamMaker, - watchdog, + var watchdog = watchdogFactory.CreateWatchdog( chatManager, - configuration, dmbFactory, - jobManager, - eventConsumer, + reattachInfoHandler, + sessionControllerFactory, + gameIoManager, + diagnosticsIOManager, + configuration, // watchdog doesn't need itself as an event consumer remoteDeploymentManagerFactory, - asyncDelayer, - loggerFactory.CreateLogger()); + metricFactory, + metadata, + metadata.DreamDaemonSettings!); + try + { + eventConsumer.SetWatchdog(watchdog); + commandFactory.SetWatchdog(watchdog); - return instance; + Instance? instance = null; + var dreamMaker = new DreamMaker( + engineManager, + gameIoManager, + configuration, + sessionControllerFactory, + eventConsumer, + chatManager, + processExecutor, + dmbFactory, + repoManager, + remoteDeploymentManagerFactory, + asyncDelayer, + metricFactory, + loggerFactory.CreateLogger(), + sessionConfiguration, + metadata); + + instance = new Instance( + metadata, + repoManager, + engineManager, + dreamMaker, + watchdog, + chatManager, + configuration, + dmbFactory, + jobManager, + eventConsumer, + remoteDeploymentManagerFactory, + asyncDelayer, + loggerFactory.CreateLogger()); + + return instance; + } + catch + { + await watchdog.DisposeAsync(); + throw; + } } catch { - await watchdog.DisposeAsync(); + await chatManager.DisposeAsync(); throw; } } catch { - await chatManager.DisposeAsync(); + engineManager.Dispose(); throw; } } diff --git a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs index 23d010cedf..69f5805fbb 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs @@ -518,17 +518,23 @@ namespace Tgstation.Server.Host.Components.Session bool apiValidate, CancellationToken cancellationToken) { - // important to run on all ports to allow port changing - var environment = await engineLock.LoadEnv(logger, false, cancellationToken); - var arguments = engineLock.FormatServerArguments( - dmbProvider, - new Dictionary + var serverMayHaveDMApi = apiValidate || dmbProvider.CompileJob.DMApiVersion != null; + + var serverArguments = serverMayHaveDMApi + ? new Dictionary { { DMApiConstants.ParamApiVersion, DMApiConstants.InteropVersion.Semver().ToString() }, { DMApiConstants.ParamServerPort, sessionConfiguration.BridgePort.ToString(CultureInfo.InvariantCulture) }, { DMApiConstants.ParamAccessIdentifier, accessIdentifier }, - }, + } + : null; + + var environment = await engineLock.LoadEnv(logger, false, cancellationToken); + var arguments = engineLock.FormatServerArguments( + dmbProvider, + serverArguments, launchParameters, + accessIdentifier, !engineLock.HasStandardOutput || engineLock.PreferFileLogging ? logFilePath : null); @@ -604,11 +610,11 @@ namespace Tgstation.Server.Host.Components.Session if (cliSupported) ddOutput = (await process.GetCombinedOutput(cancellationToken))!; - if (ddOutput == null) + if (String.IsNullOrWhiteSpace(ddOutput) && outputFilePath != null) try { var dreamDaemonLogBytes = await gameIOManager.ReadAllBytes( - outputFilePath!, + outputFilePath, cancellationToken); ddOutput = Encoding.UTF8.GetString(dreamDaemonLogBytes.Span); @@ -619,7 +625,7 @@ namespace Tgstation.Server.Host.Components.Session try { logger.LogTrace("Deleting temporary log file {path}...", outputFilePath); - await gameIOManager.DeleteFile(outputFilePath!, cancellationToken); + await gameIOManager.DeleteFile(outputFilePath, cancellationToken); } catch (Exception ex) { diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 52004ad968..b886f40feb 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -3,8 +3,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Web; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -29,6 +29,7 @@ namespace Tgstation.Server.Host.Controllers /// /// for TGS administration purposes. /// + [Authorize] [Route(Routes.Administration)] public sealed class AdministrationController : ApiController { @@ -111,7 +112,6 @@ namespace Tgstation.Server.Host.Controllers /// The GitHub API rate limit was hit. See response header Retry-After. /// A GitHub API error occurred. See error message for details. [HttpGet] - [TgsRestAuthorize(nameof(IAdministrationAuthority.GetUpdateInformation))] [ProducesResponseType(typeof(AdministrationResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 424)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] @@ -132,7 +132,6 @@ namespace Tgstation.Server.Host.Controllers /// A GitHub rate limit was encountered or the swarm integrity check failed. /// A GitHub API error occurred. [HttpPost] - [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerVersionChange))] [ProducesResponseType(typeof(ServerUpdateResponse), 202)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] @@ -160,7 +159,6 @@ namespace Tgstation.Server.Host.Controllers /// Restart begun successfully. /// Restart operations are unavailable due to the launch configuration of TGS. [HttpDelete] - [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] public ValueTask Delete() @@ -236,43 +234,9 @@ namespace Tgstation.Server.Host.Controllers [TgsAuthorize(AdministrationRights.DownloadLogs)] [ProducesResponseType(typeof(LogFileResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 409)] - public async ValueTask GetLog(string path, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(path); - - path = HttpUtility.UrlDecode(path); - - // guard against directory navigation - var sanitizedPath = ioManager.GetFileName(path); - if (path != sanitizedPath) - return Forbid(); - - var fullPath = ioManager.ConcatPath( - fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier), - path); - try - { - var fileTransferTicket = fileTransferService.CreateDownload( - new FileDownloadProvider( - () => null, - null, - fullPath, - true)); - - return Ok(new LogFileResponse - { - Name = path, - LastModified = await ioManager.GetLastModified(fullPath, cancellationToken), - FileTicket = fileTransferTicket.FileTicket, - }); - } - catch (IOException ex) - { - return Conflict(new ErrorMessageResponse(ErrorCode.IOError) - { - AdditionalData = ex.ToString(), - }); - } - } + public ValueTask GetLog(string path, CancellationToken cancellationToken) + => administrationAuthority.Invoke( + this, + authority => authority.GetLog(path, cancellationToken)); } } diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9b2b058010..771972a0f7 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -249,7 +249,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. protected ValueTask Paginated( - Func>> queryGenerator, + Func?>> queryGenerator, Func? resultTransformer, int? pageQuery, int? pageSizeQuery, @@ -272,7 +272,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. protected ValueTask Paginated( - Func>> queryGenerator, + Func?>> queryGenerator, Func? resultTransformer, int? pageQuery, int? pageSizeQuery, @@ -290,14 +290,14 @@ namespace Tgstation.Server.Host.Controllers /// /// The of model being generated. If different from , must implement for . /// The of model being returned. - /// A resulting in a resulting in the generated . + /// A resulting in a resulting in the generated or if an authorization requirment failed. /// A to transform the s after being queried. /// The requested page from the query. /// The requested page size from the query. /// The for the operation. /// A resulting in the for the operation. async ValueTask PaginatedImpl( - Func>> queryGenerator, + Func?>> queryGenerator, Func? resultTransformer, int? pageQuery, int? pageSizeQuery, @@ -318,6 +318,9 @@ namespace Tgstation.Server.Host.Controllers var page = pageQuery ?? 1; var paginationResult = await queryGenerator(); + if (paginationResult == null) + return Forbid(); + if (!paginationResult.Valid) return paginationResult.EarlyOut; diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index ab7000e9e1..d7dd5c092a 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -128,7 +127,6 @@ namespace Tgstation.Server.Host.Controllers /// /// retrieved successfully. [HttpGet] - [AllowAnonymous] [ProducesResponseType(typeof(ServerInformationResponse), 200)] public IActionResult ServerInfo() { diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index 53fd0421a3..e89960bf1f 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -161,7 +161,7 @@ namespace Tgstation.Server.Host.Controllers var response = await bridgeDispatcher.ProcessBridgeRequest(request, cancellationToken); if (response == null) - TypedResults.Forbid(); + TypedResults.Unauthorized(); var responseJson = JsonConvert.SerializeObject(response, DMApiConstants.SerializerSettings); diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index fd6cf80e1d..2852768184 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -208,7 +208,7 @@ namespace Tgstation.Server.Host.Controllers { var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0; return Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .ChatBots diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 7f8755fdd2..59e44772f8 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -126,7 +126,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( BaseCompileJobsQuery() .OrderByDescending(x => x.Job.StoppedAt))), diff --git a/src/Tgstation.Server.Host/Controllers/EngineController.cs b/src/Tgstation.Server.Host/Controllers/EngineController.cs index 83a113e3b4..89a1ba8abe 100644 --- a/src/Tgstation.Server.Host/Controllers/EngineController.cs +++ b/src/Tgstation.Server.Host/Controllers/EngineController.cs @@ -110,7 +110,7 @@ namespace Tgstation.Server.Host.Controllers public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => WithComponentInstance( instance => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( instance .EngineManager diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 8f3f53dca2..633b0d2ecb 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -612,7 +612,7 @@ namespace Tgstation.Server.Host.Controllers var needsUpdate = false; var result = await Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( GetBaseQuery() .OrderBy(x => x.Id))), diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 5b4ac2a9c4..01f51bdc0c 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -197,7 +197,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Instances diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 2b9f1b15f1..2cfb962891 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -71,7 +71,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Jobs @@ -99,7 +99,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Jobs diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index f55218ef64..fc858c41bd 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -24,6 +25,7 @@ namespace Tgstation.Server.Host.Controllers /// for managing s. /// [Route(Routes.User)] + [Authorize] public sealed class UserController : ApiController { /// @@ -64,10 +66,9 @@ namespace Tgstation.Server.Host.Controllers /// created successfully. /// The requested system identifier could not be found. [HttpPut] - [TgsRestAuthorize(nameof(IUserAuthority.Create))] [ProducesResponseType(typeof(UserResponse), 201)] public ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) - => userAuthority.InvokeTransformable(this, authority => authority.Create(model, null, cancellationToken)); + => userAuthority.InvokeTransformable(this, authority => authority.Create(model, null, cancellationToken)); /// /// Update a . @@ -80,13 +81,12 @@ namespace Tgstation.Server.Host.Controllers /// Requested does not exist. /// Requested does not exist. [HttpPost] - [TgsRestAuthorize(nameof(IUserAuthority.Update))] [ProducesResponseType(typeof(UserResponse), 200)] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 404)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] public ValueTask Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken) - => userAuthority.InvokeTransformable(this, authority => authority.Update(model, cancellationToken)); + => userAuthority.InvokeTransformable(this, authority => authority.Update(model, cancellationToken)); /// /// Get information about the current . @@ -95,7 +95,7 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the operation. /// The was retrieved successfully. [HttpGet] - [TgsRestAuthorize(nameof(IUserAuthority.Read))] + [Authorize] [ProducesResponseType(typeof(UserResponse), 200)] public ValueTask Read(CancellationToken cancellationToken) => userAuthority.InvokeTransformable(this, authority => authority.Read(cancellationToken)); @@ -109,15 +109,18 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsRestAuthorize(nameof(IUserAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( - new PaginatableResult( - userAuthority.InvokeQueryable( - authority => authority.Queryable(true)) - .OrderBy(x => x.Id))), + async () => + { + var queryable = await userAuthority.InvokeQueryable( + authority => authority.Queryable(true)); + if (queryable == null) + return null; + + return new PaginatableResult(queryable.OrderBy(x => x.Id)); + }, null, page, pageSize, @@ -132,7 +135,6 @@ namespace Tgstation.Server.Host.Controllers /// The was retrieved successfully. /// The does not exist. [HttpGet("{id}")] - [TgsAuthorize] [ProducesResponseType(typeof(UserResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 404)] public async ValueTask GetId(long id, CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 3ff0f0bcac..0a22a428d4 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -10,7 +11,6 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; @@ -24,6 +24,7 @@ namespace Tgstation.Server.Host.Controllers /// for managing s. /// [Route(Routes.UserGroup)] + [Authorize] public class UserGroupController : ApiController { /// @@ -77,7 +78,6 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the operation. /// created successfully. [HttpPut] - [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(UserGroupResponse), 201)] public async ValueTask Create([FromBody] UserGroupCreateRequest model, CancellationToken cancellationToken) { @@ -103,7 +103,6 @@ namespace Tgstation.Server.Host.Controllers /// updated successfully. /// The requested does not currently exist. [HttpPost] - [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(UserGroupResponse), 200)] public ValueTask Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken) { @@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Controllers /// Retrieve successfully. /// The requested does not currently exist. [HttpGet("{id}")] - [TgsRestAuthorize(nameof(IUserGroupAuthority.GetId))] [ProducesResponseType(typeof(UserGroupResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] public ValueTask GetId(long id, CancellationToken cancellationToken) @@ -142,15 +140,18 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsRestAuthorize(nameof(IUserGroupAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( - new PaginatableResult( - userGroupAuthority - .InvokeQueryable(authority => authority.Queryable(true)) - .OrderBy(x => x.Id))), + async () => + { + var queryable = await userGroupAuthority + .InvokeQueryable(authority => authority.Queryable(true)); + if (queryable == null) + return null; + + return new PaginatableResult(queryable.OrderBy(x => x.Id)); + }, null, page, pageSize, @@ -166,7 +167,6 @@ namespace Tgstation.Server.Host.Controllers /// The is not empty. /// The didn't exist. [HttpDelete("{id}")] - [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 409)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 73293c3ab0..9d1b8eff24 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Hosting; @@ -49,7 +50,6 @@ using Serilog.Sinks.Elasticsearch; using Tgstation.Server.Api; using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Components; @@ -309,8 +309,12 @@ namespace Tgstation.Server.Host.Core services.AddCors(); // Enable managed HTTP clients - services.AddHttpClient(); - services.AddSingleton(); + services + .AddHttpClient() + .ConfigureHttpClientDefaults( + builder => builder.ConfigureHttpClient( + client => client.DefaultRequestHeaders.UserAgent.Add( + assemblyInformationProvider.ProductInfoHeaderValue))); services.AddSingleton(_ => Metrics.DefaultFactory); services.AddSingleton(_ => Metrics.DefaultRegistry); @@ -325,21 +329,6 @@ namespace Tgstation.Server.Host.Core services .AddScoped() .AddGraphQLServer() - .AddAuthorization( - options => - { - options.AddPolicy( - TgsAuthorizeAttribute.PolicyName, - builder => builder - .RequireAuthenticatedUser() - .RequireRole(TgsAuthorizeAttribute.UserEnabledRole)); - - options.AddPolicy( - SwarmConstants.AuthenticationSchemeAndPolicy, - builder => builder - .RequireAuthenticatedUser() - .AddAuthenticationSchemes(SwarmConstants.AuthenticationSchemeAndPolicy)); - }) .ModifyOptions(options => { options.EnsureAllNodesCanBeResolved = true; @@ -373,10 +362,10 @@ namespace Tgstation.Server.Host.Core .AddFiltering() .AddSorting() .AddHostTypes() + .AddAuthorization() .AddErrorFilter() .AddType() .AddType() - .AddType() .AddType() .AddType() .BindRuntimeType() @@ -843,6 +832,10 @@ namespace Tgstation.Server.Host.Core services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // what if you // wanted to just do this: // return provider.GetRequiredService().CurrentAuthenticationContext @@ -899,6 +892,23 @@ namespace Tgstation.Server.Host.Core authBuilder.AddScheme(SwarmConstants.AuthenticationSchemeAndPolicy, "Swarm Authentication", null); + services.AddAuthorization(options => + { + options.AddPolicy( + TgsAuthorizeAttribute.PolicyName, + builder => builder + .RequireAuthenticatedUser() + .RequireRole(TgsAuthorizeAttribute.UserEnabledRole)); + + options.AddPolicy( + SwarmConstants.AuthenticationSchemeAndPolicy, + builder => builder + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(SwarmConstants.AuthenticationSchemeAndPolicy)); + + options.DefaultPolicy = options.GetPolicy(TgsAuthorizeAttribute.PolicyName)!; + }); + var oidcConfig = securityConfiguration.OpenIDConnect; if (oidcConfig == null || oidcConfig.Count == 0) return; diff --git a/src/Tgstation.Server.Host/Extensions/ClaimsPrincipalExtensions.cs b/src/Tgstation.Server.Host/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..3b9c5c0039 --- /dev/null +++ b/src/Tgstation.Server.Host/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.Security.Claims; + +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Tgstation.Server.Host.Extensions +{ + /// + /// Extension methods for the class. + /// + static class ClaimsPrincipalExtensions + { + /// + /// Parse the out of a given authenticated . + /// + /// The to use to parse the user ID. + /// The user ID in the if it was present. + public static long? GetTgsUserId(this ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(principal); + + var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (userIdClaim == default) + return null; + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + return userId; + } + + /// + /// Parse the out of a given authenticated . + /// + /// The to use to parse the user ID. + /// The user ID in the . + public static long RequireTgsUserId(this ClaimsPrincipal principal) + => principal.GetTgsUserId() ?? throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); + + /// + /// Parse a out of a in a given . + /// + /// The containing claims. + /// The name to parse from. + /// The parsed . + public static DateTimeOffset ParseTime(this ClaimsPrincipal principal, string claimName) + { + ArgumentNullException.ThrowIfNull(principal); + ArgumentNullException.ThrowIfNull(claimName); + + var claim = principal.FindFirst(claimName); + if (claim == null) + throw new InvalidOperationException($"Missing '{claimName}' claim!"); + + try + { + return new DateTimeOffset( + EpochTime.DateTime( + Int64.Parse(claim.Value, CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to parse claim {claimName}: '{claim.Value}'!", ex); + } + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/AuthorizationHelper.cs b/src/Tgstation.Server.Host/GraphQL/AuthorizationHelper.cs new file mode 100644 index 0000000000..02756b1f9f --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/AuthorizationHelper.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using HotChocolate; + +using Microsoft.AspNetCore.Authorization; + +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// Helper for authorization functionality related to GraphQL. + /// + static class AuthorizationHelper + { + /// + /// Create a new to be thrown when a forbidden error occurs. + /// + /// The . + /// A new . + public static GraphQLException ForbiddenGraphQLException(this AuthorizationFailure authorizationFailure) + { + ArgumentNullException.ThrowIfNull(authorizationFailure); + + var messageBuilder = new StringBuilder("The current user is not authorized to access this resource."); + + foreach (var failureReason in authorizationFailure.FailureReasons) + { + messageBuilder.AppendLine(); + messageBuilder.Append("\t- "); + messageBuilder.Append(failureReason.Message); + } + + return new(ErrorBuilder.New() + .SetMessage(messageBuilder.ToString()) // Copied from graphql-platform: AuthorizeMiddleware.cs + .SetCode(ErrorCodes.Authentication.NotAuthorized) + .Build()); + } + + /// + /// Evaluate a given set of , throwing the approriate on failure. + /// + /// The authorization service to use. + /// The of s to evaluate.. + /// If the should be excluded. + /// A representing the running operation. + public static async ValueTask CheckGraphQLAuthorized( + this Security.IAuthorizationService authorizationService, + IEnumerable? authorizationRequirements, + bool excludeUserSessionValidRequirement = false) + { + ArgumentNullException.ThrowIfNull(authorizationService); + ArgumentNullException.ThrowIfNull(authorizationRequirements); + + if (!excludeUserSessionValidRequirement) + authorizationRequirements = UserSessionValidRequirement.InstanceAsEnumerable.Concat(authorizationRequirements); + + var result = await authorizationService.AuthorizeAsync(authorizationRequirements); + if (!result.Succeeded) + throw result.Failure.ForbiddenGraphQLException(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs index 1de582c06f..886ee78f47 100644 --- a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -1,5 +1,7 @@ using System.Linq; +using HotChocolate.Authorization; + using Tgstation.Server.Host.GraphQL.Types; namespace Tgstation.Server.Host.GraphQL.Interfaces @@ -18,7 +20,8 @@ namespace Tgstation.Server.Host.GraphQL.Interfaces /// /// Queries all s in the . /// - /// Queryable s. + /// Queryable s in the . + [Authorize] IQueryable Instances(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 286fe00619..2bb7231021 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Types; using Tgstation.Server.Host.Authority; @@ -14,6 +15,7 @@ namespace Tgstation.Server.Host.GraphQL /// Root type for GraphQL mutations. /// /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + [Authorize] [GraphQLDescription(GraphQLDescription)] public sealed class Mutation { @@ -28,6 +30,7 @@ namespace Tgstation.Server.Host.GraphQL /// The for the . /// The for the operation. /// A . + [AllowAnonymous] [Error(typeof(ErrorMessageException))] public ValueTask Login( [Service] IGraphQLAuthorityInvoker loginAuthority, @@ -45,6 +48,7 @@ namespace Tgstation.Server.Host.GraphQL /// The for the . /// The for the operation. /// An . + [AllowAnonymous] [Error(typeof(ErrorMessageException))] public ValueTask OAuthGateway( [Service] IGraphQLAuthorityInvoker loginAuthority, diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs index 7390113a24..39d1513ca6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs @@ -6,10 +6,8 @@ using HotChocolate; using HotChocolate.Types; using Tgstation.Server.Api.Models.Response; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Scalars; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Mutations { @@ -25,7 +23,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// /// The for the . /// A representing the running operation. - [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [Error(typeof(ErrorMessageException))] public async ValueTask RestartServerNode( [Service] IGraphQLAuthorityInvoker administrationAuthority) @@ -44,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// A representing the running operation. - [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] [Error(typeof(ErrorMessageException))] public async ValueTask ChangeServerNodeVersionViaTrackedRepository( Version targetVersion, @@ -65,9 +61,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// A FileTicket that should be used to upload a zip containing the update data to the file transfer service. - [TgsGraphQLAuthorize(AdministrationRights.UploadVersion)] - [Error(typeof(ErrorMessageException))] [GraphQLType] + [Error(typeof(ErrorMessageException))] public async ValueTask ChangeServerNodeVersionViaUpload( Version targetVersion, [Service] IGraphQLAuthorityInvoker administrationAuthority, diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs index 7f27d8ea76..912780cacb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -10,7 +10,6 @@ using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Mutations { @@ -43,7 +42,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserGroup( string name, @@ -67,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Update))] [Error(typeof(ErrorMessageException))] public ValueTask UpdateUserGroup( [ID(nameof(UserGroup))] long id, @@ -88,7 +85,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The root. - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.DeleteEmpty))] [Error(typeof(ErrorMessageException))] public async ValueTask DeleteEmptyUserGroup( [ID(nameof(UserGroup))] long id, diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 1653ef0165..b616be34d2 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -9,7 +9,6 @@ using HotChocolate.Types; using HotChocolate.Types.Relay; using Tgstation.Server.Api.Models.Request; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.GraphQL.Types; @@ -38,9 +37,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserByPasswordAndPermissionSet( + public ValueTask CreateUserByPasswordAndPermissionSet( string name, string password, bool? enabled, @@ -54,7 +52,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(password); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -99,9 +97,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserByPasswordAndGroup( + public ValueTask CreateUserByPasswordAndGroup( string name, string password, bool? enabled, @@ -115,7 +112,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(password); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -156,9 +153,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserByServiceConnectionAndPermissionSet( + public ValueTask CreateUserByServiceConnectionAndPermissionSet( string name, IEnumerable? oAuthConnections, IEnumerable? oidcConnections, @@ -171,7 +167,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(oAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -215,9 +211,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserByServiceConnectionAndGroup( + public ValueTask CreateUserByServiceConnectionAndGroup( string name, IEnumerable oAuthConnections, IEnumerable oidcConnections, @@ -230,7 +225,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(oAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -271,9 +266,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserBySystemIDAndPermissionSet( + public ValueTask CreateUserBySystemIDAndPermissionSet( string systemIdentifier, bool? enabled, IEnumerable? oAuthConnections, @@ -285,7 +279,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(systemIdentifier); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -328,9 +322,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserBySystemIDAndGroup( + public ValueTask CreateUserBySystemIDAndGroup( string systemIdentifier, bool? enabled, [ID(nameof(UserGroup))] long groupId, @@ -342,7 +335,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(systemIdentifier); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { @@ -379,9 +372,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] [Error(typeof(ErrorMessageException))] - public ValueTask SetCurrentUserPassword( + public ValueTask SetCurrentUserPassword( string newPassword, [Service] IAuthenticationContext authenticationContext, [Service] IGraphQLAuthorityInvoker userAuthority, @@ -389,8 +381,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations { ArgumentNullException.ThrowIfNull(newPassword); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( - async authority => await authority.Update( + return userAuthority.InvokeTransformable( + authority => authority.Update( new UserUpdateRequest { Id = authenticationContext.User.Id, @@ -408,9 +400,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnServiceConnections)] [Error(typeof(ErrorMessageException))] - public ValueTask SetCurrentServiceConnections( + public ValueTask SetCurrentServiceConnections( IEnumerable? newOAuthConnections, IEnumerable? newOidcConnections, [Service] IAuthenticationContext authenticationContext, @@ -419,8 +410,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations { ArgumentNullException.ThrowIfNull(newOAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( - async authority => await authority.Update( + return userAuthority.InvokeTransformable( + authority => authority.Update( new UserUpdateRequest { Id = authenticationContext.User.Id, @@ -454,9 +445,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] - public ValueTask UpdateUser( + public ValueTask UpdateUser( [ID(nameof(User))] long id, string? casingOnlyNameChange, string? newPassword, @@ -493,9 +483,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] - public ValueTask UpdateUserSetOwnedPermissionSet( + public ValueTask UpdateUserSetOwnedPermissionSet( [ID(nameof(User))] long id, string? casingOnlyNameChange, string? newPassword, @@ -533,9 +522,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] - public ValueTask UpdateUserSetGroup( + public ValueTask UpdateUserSetGroup( [ID(nameof(User))] long id, string? casingOnlyNameChange, string? newPassword, @@ -574,7 +562,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - ValueTask UpdateUserCore( + ValueTask UpdateUserCore( [ID(nameof(User))] long id, string? casingOnlyNameChange, string? newPassword, @@ -585,8 +573,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations IEnumerable? newOidcConnections, IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) - => userAuthority.InvokeTransformable( - async authority => await authority.Update( + => userAuthority.InvokeTransformable( + authority => authority.Update( new UserUpdateRequest { Id = id, diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs index 170dceb49a..9361246dea 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscription.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Execution; using HotChocolate.Subscriptions; using HotChocolate.Types; @@ -16,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL /// Root type for GraphQL subscriptions. /// /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + [Authorize(ApplyPolicy.Validation)] // See https://github.com/ChilliCream/graphql-platform/issues/6259 [GraphQLDescription(GraphQLDescription)] public sealed class Subscription { @@ -63,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL /// The received from the publisher. /// The . [Subscribe(With = nameof(SessionInvalidatedStream))] - [TgsGraphQLAuthorize] public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason) => sessionInvalidationReason; } diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs index 35be564c52..2b23feba9e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -8,7 +8,6 @@ using HotChocolate.Execution; using HotChocolate.Types; using HotChocolate.Types.Relay; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.Security; @@ -68,7 +67,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions /// The received from the publisher. /// The updated . [Subscribe(With = nameof(UserUpdatedStream))] - [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] public User UserUpdated([EventMessage] User user) { ArgumentNullException.ThrowIfNull(user); @@ -98,7 +96,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions /// The received from the publisher. /// The updated . [Subscribe(With = nameof(CurrentUserUpdatedStream))] - [TgsGraphQLAuthorize] public User CurrentUserUpdated([EventMessage] User user) { ArgumentNullException.ThrowIfNull(user); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs index 9a1aee31cf..b73aa62f64 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using Microsoft.Extensions.Options; @@ -14,6 +16,7 @@ using Tgstation.Server.Host.GraphQL.Types.OAuth; using Tgstation.Server.Host.Properties; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.Security.RightsEvaluation; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.GraphQL.Types @@ -23,70 +26,117 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class GatewayInformation { + /// + /// Access the GraphQL API without auth. + /// + static Version GraphQLApiVersionNoAuth { get; } = global::System.Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion); + + /// + /// Gets the major GraphQL API number of the . + /// + public int MajorGraphQLApiVersion => GraphQLApiVersionNoAuth.Major; + /// /// Gets the minimum valid password length for TGS users. /// + /// The to use. /// The containing the . /// A specifying the minimumn valid password length for TGS users. - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] - public uint MinimumPasswordLength( + [Authorize] + public async ValueTask MinimumPasswordLength( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + await authorizationService.CheckGraphQLAuthorized( + [new OrRightsConditional( + new FlagRightsConditional(AdministrationRights.WriteUsers), + new FlagRightsConditional(AdministrationRights.EditOwnPassword))]); + return generalConfigurationOptions.Value.MinimumPasswordLength; } /// /// Gets the maximum allowed attached instances for the . /// + /// The to use. /// The containing the . /// A specifying the maximum allowed attached instances for the . - [TgsGraphQLAuthorize(InstanceManagerRights.Create)] - public uint InstanceLimit( + [Authorize] + public async ValueTask InstanceLimit( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + await authorizationService.CheckGraphQLAuthorized( + [new FlagRightsConditional(InstanceManagerRights.Create)]); + return generalConfigurationOptions.Value.InstanceLimit; } /// /// Gets the maximum allowed registered s for the . /// + /// The to use. /// The containing the . /// A specifying the maximum allowed registered users for the . /// This limit only applies to user creation attempts made via the current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] - public uint UserLimit( + [Authorize] + public async ValueTask UserLimit( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + await authorizationService.CheckGraphQLAuthorized( + [new FlagRightsConditional(AdministrationRights.WriteUsers)]); + return generalConfigurationOptions.Value.UserLimit; } /// /// Gets the maximum allowed registered s for the . /// + /// The to use. /// The containing the . /// A specifying the maximum allowed registered s for the . /// This limit only applies to creation attempts made via the current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] - public uint UserGroupLimit( + [Authorize] + public async ValueTask UserGroupLimit( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + await authorizationService.CheckGraphQLAuthorized( + [new FlagRightsConditional(AdministrationRights.WriteUsers)]); return generalConfigurationOptions.Value.UserGroupLimit; } /// /// Gets the locations s may be created or attached from if there are restrictions. /// + /// The to use. /// The containing the . /// The locations s may be created or attached from if there are restrictions, otherwise. - [TgsGraphQLAuthorize(InstanceManagerRights.Create | InstanceManagerRights.Relocate)] - public IReadOnlyCollection? ValidInstancePaths( + [Authorize] + public async ValueTask?> ValidInstancePaths( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + await authorizationService.CheckGraphQLAuthorized( + [new OrRightsConditional( + new FlagRightsConditional(InstanceManagerRights.Create), + new FlagRightsConditional(InstanceManagerRights.Relocate))]); return generalConfigurationOptions.Value.ValidInstancePaths; } @@ -95,26 +145,29 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// The to use. /// if the runs on a Windows operating system, otherwise. - [TgsGraphQLAuthorize] + [Authorize] public bool WindowsHost( [Service] IPlatformIdentifier platformIdentifier) { ArgumentNullException.ThrowIfNull(platformIdentifier); + return platformIdentifier.IsWindows; } /// /// Gets the swarm protocol . /// - [TgsGraphQLAuthorize] - public Version SwarmProtocolVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion); + /// The swarm protocol . + [Authorize] + public Version SwarmProtocolVersion() + => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion); /// /// Gets the of tgstation-server the is running. /// /// The to use. /// The of tgstation-server the is running. - [TgsGraphQLAuthorize] + [Authorize] public Version Version( [Service] IAssemblyInformationProvider assemblyInformationProvider) { @@ -122,28 +175,28 @@ namespace Tgstation.Server.Host.GraphQL.Types return assemblyInformationProvider.Version; } - /// - /// Gets the major GraphQL API number of the . - /// - public int MajorGraphQLApiVersion => GraphQLApiVersion.Major; - /// /// Gets the GraphQL API of the . /// - [TgsGraphQLAuthorize] - public Version GraphQLApiVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion); + /// The GraphQL API of the . + [Authorize] + public Version GraphQLApiVersion() + => GraphQLApiVersionNoAuth; /// /// Gets the REST API of the . /// - [TgsGraphQLAuthorize] - public Version ApiVersion => ApiHeaders.Version; + /// The REST API of the . + [Authorize] + public Version ApiVersion() => ApiHeaders.Version; /// /// Gets the DMAPI interop the uses. /// - [TgsGraphQLAuthorize] - public Version DMApiVersion => DMApiConstants.InteropVersion; + /// Yhe DMAPI interop the uses. + [Authorize] + public Version DMApiVersion() + => DMApiConstants.InteropVersion; /// /// Gets the information needed to perform open authentication with the . diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs deleted file mode 100644 index fcb9f088f4..0000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Linq; - -using Tgstation.Server.Host.GraphQL.Interfaces; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// for accessing remote s. - /// - /// This is currently unimplemented. - public sealed class RemoteGateway : IGateway - { - /// - public GatewayInformation Information() => throw new NotImplementedException(); - - /// - public IQueryable Instances() => throw new NotImplementedException(); - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 35d82985dd..89cba488f1 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -3,13 +3,14 @@ using System.Collections.Generic; using System.Linq; using HotChocolate; +using HotChocolate.Authorization; using Microsoft.Extensions.Options; +using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Properties; -using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Swarm; namespace Tgstation.Server.Host.GraphQL.Types @@ -19,17 +20,27 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class ServerSwarm { + /// + /// Access all instances in the . + /// + /// Queryable in the . + [Authorize] + public IQueryable Instances() + => throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); + /// /// Gets the swarm protocol major version in use. /// - [TgsGraphQLAuthorize] - public int ProtocolMajorVersion => Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major; + /// The swarm protocol major version in use. + [Authorize] + public int ProtocolMajorVersion() + => Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major; /// /// Gets the swarm's . /// /// A new . - [TgsGraphQLAuthorize] + [Authorize] public Users Users() => new(); /// @@ -37,7 +48,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// The containing the current . /// The to use. - /// A new for the local node if it is part of a swarm, otherwise. + /// The for the local node if it is part of a swarm, a otherwise. public IServerNode CurrentNode( [Service] IOptionsSnapshot swarmConfigurationOptions, [Service] ISwarmService swarmService) @@ -45,11 +56,12 @@ namespace Tgstation.Server.Host.GraphQL.Types ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); ArgumentNullException.ThrowIfNull(swarmService); - var ourIdentifier = swarmConfigurationOptions.Value.Identifier; - if (ourIdentifier == null) + if (swarmConfigurationOptions.Value.PrivateKey == null) return new StandaloneNode(); - return (IServerNode?)SwarmNode.GetSwarmNode(ourIdentifier, swarmService) ?? new StandaloneNode(); + return ((IServerNode?)SwarmNode.GetSwarmNode( + swarmConfigurationOptions.Value.Identifier!, + swarmService)) ?? new StandaloneNode(); } /// @@ -57,7 +69,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// The to use. /// A of s if the local node is part of a swarm, otherwise. - [TgsGraphQLAuthorize] + [Authorize] public List? Nodes( [Service] ISwarmService swarmService) { @@ -69,7 +81,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// Gets the for the swarm. /// /// A new . - [TgsGraphQLAuthorize] + [Authorize] public UpdateInformation UpdateInformation() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs index 520f8bd6c7..e0a2068908 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -2,6 +2,7 @@ using System.Linq; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Types.Relay; using Microsoft.Extensions.Options; @@ -10,7 +11,6 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.GraphQL.Interfaces; -using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Swarm; namespace Tgstation.Server.Host.GraphQL.Types @@ -53,13 +53,14 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The . /// The to load from. /// A new with the matching if found, otherwise. - [TgsGraphQLAuthorize] + [Authorize] public static SwarmNode? GetSwarmNode( string identifier, [Service] ISwarmService swarmService) { ArgumentNullException.ThrowIfNull(identifier); ArgumentNullException.ThrowIfNull(swarmService); + var info = swarmService .GetSwarmServers() ?.FirstOrDefault(x => x.Identifier == identifier); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UpdatedUser.cs b/src/Tgstation.Server.Host/GraphQL/Types/UpdatedUser.cs new file mode 100644 index 0000000000..41cd8959df --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UpdatedUser.cs @@ -0,0 +1,45 @@ +using System; + +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a that has been updated. + /// + public sealed class UpdatedUser + { + /// + /// The 's . + /// + [ID(nameof(Types.User))] + public long Id { get; } + + /// + /// The , if was authorized to be read. + /// + public User? User { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of containing the . + public UpdatedUser(Models.User user) + : this((user ?? throw new ArgumentNullException(nameof(user))).Require(u => u.Id)) + { + User = ((IApiTransformable)user).ToApi(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public UpdatedUser(long id) + { + Id = id; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 7de3e7ca2a..85cba1c170 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -3,13 +3,13 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.GraphQL.Types.OAuth; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { @@ -17,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// A user registered in the server. /// [Node] + [Authorize] public sealed class User : NamedEntity, IUserName { /// @@ -58,7 +59,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the . /// The for the operation. /// A resulting in the queried , if present. - [TgsGraphQLAuthorize] public static ValueTask GetUser( long id, [Service] IGraphQLAuthorityInvoker userAuthority, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 2511ab438a..21fa526ba7 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -4,13 +4,13 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Data; using HotChocolate.Types; using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { @@ -18,6 +18,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// Represents a group of s. /// [Node] + [Authorize] public sealed class UserGroup : NamedEntity { /// @@ -27,7 +28,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the . /// The for the operation. /// A resulting in the queried , if present. - [TgsGraphQLAuthorize] public static ValueTask GetUserGroup( long id, [Service] IGraphQLAuthorityInvoker userGroupAuthority, @@ -62,15 +62,14 @@ namespace Tgstation.Server.Host.GraphQL.Types [UsePaging] [UseFiltering] [UseSorting] - [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsersByGroup( + public async ValueTask> QueryableUsersByGroup( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable( + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( authority => authority - .Queryable(false) - .Where(user => user.GroupId == Id)); + .Queryable(false), + queryable => queryable.Where(user => user.GroupId == Id)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index eb9fc04a52..e086dcfe7a 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -4,31 +4,34 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Data; using HotChocolate.Types; using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Wrapper for accessing s. /// + [Authorize] public sealed class UserGroups { /// /// Gets the current . /// /// The for the . + /// The for the operation. /// A resulting in the current 's . public ValueTask Current( - [Service] IGraphQLAuthorityInvoker userGroupAuthority) + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read()); + return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read(cancellationToken)); } /// @@ -38,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the . /// The for the operation. /// The represented by , if any. - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.GetId))] public ValueTask ById( [ID(nameof(UserGroup))] long id, [Service] IGraphQLAuthorityInvoker userGroupAuthority, @@ -53,12 +55,12 @@ namespace Tgstation.Server.Host.GraphQL.Types [UsePaging] [UseFiltering] [UseSorting] - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] - public IQueryable QueryableGroups( + public async ValueTask> QueryableGroups( [Service] IGraphQLAuthorityInvoker userGroupAuthority) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + var dtoQueryable = await userGroupAuthority.InvokeTransformableQueryable( + authority => authority.Queryable(false)); return dtoQueryable; } @@ -71,16 +73,15 @@ namespace Tgstation.Server.Host.GraphQL.Types [UsePaging] [UseFiltering] [UseSorting] - [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsersByGroupId( + public async ValueTask> QueryableUsersByGroupId( [ID(nameof(UserGroup))]long groupId, [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable( + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( authority => authority - .Queryable(false) - .Where(user => user.GroupId == groupId)); + .Queryable(false), + queryable => queryable.Where(user => user.GroupId == groupId)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 7175c2935e..4e2fb04ff6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -4,12 +4,12 @@ using System.Threading; using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Authorization; using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { @@ -17,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// A with limited fields. /// [Node] + [Authorize] public sealed class UserName : NamedEntity, IUserName { /// @@ -26,7 +27,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the . /// The for the operation. /// A resulting in the queried , if present. - [TgsGraphQLAuthorize] public static ValueTask GetUserName( long id, [Service] IGraphQLAuthorityInvoker userAuthority, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 8ec3667fe3..75369c3b35 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -13,7 +13,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; #pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off @@ -48,7 +47,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the . /// The for the operation. /// A resulting in the current . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Read))] public ValueTask Current( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) @@ -65,7 +63,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// The for the operation. /// The represented by , if any. [Error(typeof(ErrorMessageException))] - [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))] public ValueTask ById( [ID(nameof(User))] long id, [Service] IGraphQLAuthorityInvoker userAuthority, @@ -80,12 +77,12 @@ namespace Tgstation.Server.Host.GraphQL.Types [UsePaging] [UseFiltering] [UseSorting] - [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsers( + public async ValueTask> QueryableUsers( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( + authority => authority.Queryable(false)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs index 858d19b912..57d8f2b5c5 100644 --- a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs +++ b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.IO @@ -258,8 +259,18 @@ namespace Tgstation.Server.Host.IO => ResolvePath(CurrentDirectory); /// - public virtual string ResolvePath(string path) - => fileSystem.Path.GetFullPath(path ?? throw new ArgumentNullException(nameof(path))); + public string ResolvePath(string path) + { + if (fileSystem.Path.IsPathRooted(path ?? throw new ArgumentNullException(nameof(path)))) + { + // Important to evaluate the path anyway to normalize front slashes to backslashes on Windows + // Some tools (looking at you netsh.exe) bitch if you pass them forward slashes as directory separators + // Can't rely on ResolvePathCore to do this either because its contract stipulates a relative path + return fileSystem.Path.GetFullPath(path); + } + + return ResolvePathCore(path); + } /// public async ValueTask WriteAllBytes(string path, ReadOnlyMemory contents, CancellationToken cancellationToken) @@ -335,26 +346,46 @@ namespace Tgstation.Server.Host.IO TaskScheduler.Current); /// - public Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken) => Task.Factory.StartNew( - () => - { - path = ResolvePath(path); - ArgumentNullException.ThrowIfNull(zipFile); + public async ValueTask ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken) + { + path = ResolvePath(path); + ArgumentNullException.ThrowIfNull(zipFile); #if NET11_0_OR_GREATER #error Check if zip file seeking has been addressed. See https://github.com/tgstation/tgstation-server/issues/1531 #endif - // ZipArchive does a synchronous copy on unseekable streams we want to avoid - if (!zipFile.CanSeek) - throw new ArgumentException("Stream does not support seeking!", nameof(zipFile)); + // ZipArchive does a synchronous copy on unseekable streams we want to avoid + if (!zipFile.CanSeek) + throw new ArgumentException("Stream does not support seeking!", nameof(zipFile)); - using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read, true); - archive.ExtractToDirectory(path); - }, - cancellationToken, - BlockingTaskCreationOptions, - TaskScheduler.Current); + using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read, true); + + // start async context + await Task.Yield(); + foreach (var entry in archive.Entries) + { + var entryPath = fileSystem.Path.Combine(path, entry.FullName); + + if (string.IsNullOrEmpty(entry.Name)) + { + fileSystem.Directory.CreateDirectory(entryPath); + continue; + } + + var directoryPath = fileSystem.Path.GetDirectoryName(entryPath); + if (directoryPath == null) + { + throw new JobException("Zip archive concatenation resulted in a null directory path!"); + } + + fileSystem.Directory.CreateDirectory(directoryPath); + + using var entryStream = entry.Open(); + using var outputStream = fileSystem.File.Create(entryPath); + await entryStream.CopyToAsync(outputStream, cancellationToken); + } + } /// public bool PathContainsParentAccess(string path) => path @@ -422,7 +453,7 @@ namespace Tgstation.Server.Host.IO { ArgumentNullException.ThrowIfNull(subdirectoryPath); - if (!Path.IsPathRooted(subdirectoryPath)) + if (!fileSystem.Path.IsPathRooted(subdirectoryPath)) subdirectoryPath = ConcatPath( ResolvePath(), subdirectoryPath); @@ -432,6 +463,14 @@ namespace Tgstation.Server.Host.IO subdirectoryPath); } + /// + /// Resolve a given, non-rooted, . + /// + /// The non-rooted path to resolve. + /// The fully resolved path. + protected virtual string ResolvePathCore(string path) + => fileSystem.Path.GetFullPath(path ?? throw new ArgumentNullException(nameof(path))); + /// /// Copies a directory from to . /// diff --git a/src/Tgstation.Server.Host/IO/FileDownloader.cs b/src/Tgstation.Server.Host/IO/FileDownloader.cs index 30efdb6718..10b148136f 100644 --- a/src/Tgstation.Server.Host/IO/FileDownloader.cs +++ b/src/Tgstation.Server.Host/IO/FileDownloader.cs @@ -5,7 +5,6 @@ using System.Net.Http.Headers; using Microsoft.Extensions.Logging; using Tgstation.Server.Api; -using Tgstation.Server.Common.Http; namespace Tgstation.Server.Host.IO { @@ -13,9 +12,9 @@ namespace Tgstation.Server.Host.IO public sealed class FileDownloader : IFileDownloader { /// - /// The for the . + /// The for the . /// - readonly IAbstractHttpClientFactory httpClientFactory; + readonly IHttpClientFactory httpClientFactory; /// /// The for the . @@ -27,7 +26,7 @@ namespace Tgstation.Server.Host.IO /// /// The value of . /// The value of . - public FileDownloader(IAbstractHttpClientFactory httpClientFactory, ILogger logger) + public FileDownloader(IHttpClientFactory httpClientFactory, ILogger logger) { this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/Tgstation.Server.Host/IO/IIOManager.cs b/src/Tgstation.Server.Host/IO/IIOManager.cs index 31148b4a87..ea2d06fc89 100644 --- a/src/Tgstation.Server.Host/IO/IIOManager.cs +++ b/src/Tgstation.Server.Host/IO/IIOManager.cs @@ -238,8 +238,8 @@ namespace Tgstation.Server.Host.IO /// The path to unzip to. /// The of the . Must have set to . Will be read completely and left open. will be indeterminate. /// The for the operation. - /// A representing the running operation. - Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken); + /// A representing the running operation. + ValueTask ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken); /// /// Get the of when a given was last modified. diff --git a/src/Tgstation.Server.Host/IO/RequestFileStreamProvider.cs b/src/Tgstation.Server.Host/IO/RequestFileStreamProvider.cs index 12461e4e7f..ef82a2bf53 100644 --- a/src/Tgstation.Server.Host/IO/RequestFileStreamProvider.cs +++ b/src/Tgstation.Server.Host/IO/RequestFileStreamProvider.cs @@ -14,9 +14,9 @@ namespace Tgstation.Server.Host.IO sealed class RequestFileStreamProvider : IFileStreamProvider { /// - /// The for the . + /// The for the . /// - readonly IHttpClient httpClient; + readonly HttpClient httpClient; /// /// The for the . @@ -43,7 +43,7 @@ namespace Tgstation.Server.Host.IO /// /// The value of . /// The value of . - public RequestFileStreamProvider(IHttpClient httpClient, HttpRequestMessage requestMessage) + public RequestFileStreamProvider(HttpClient httpClient, HttpRequestMessage requestMessage) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.requestMessage = requestMessage ?? throw new ArgumentNullException(nameof(requestMessage)); diff --git a/src/Tgstation.Server.Host/IO/ResolvingIOManager.cs b/src/Tgstation.Server.Host/IO/ResolvingIOManager.cs index 625d7da86e..0be02cd2e2 100644 --- a/src/Tgstation.Server.Host/IO/ResolvingIOManager.cs +++ b/src/Tgstation.Server.Host/IO/ResolvingIOManager.cs @@ -27,11 +27,8 @@ namespace Tgstation.Server.Host.IO } /// - public override string ResolvePath(string path) - { - if (!IsPathRooted(path)) - return base.ResolvePath(ConcatPath(subdirectory, path)); - return path; - } + protected override string ResolvePathCore(string path) + => base.ResolvePathCore( + ConcatPath(subdirectory, path)); } } diff --git a/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs index e3d16441cc..f11202bd1f 100644 --- a/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs +++ b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs @@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Models.Transformers /// /// cache for . /// - static Func? compiledExpression; + static Func? compiledExpression; // This is safe https://stackoverflow.com/a/9647661/3976486 /// public Expression> Expression { get; } diff --git a/src/Tgstation.Server.Host/Models/Transformers/UpdatedUserGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UpdatedUserGraphQLTransformer.cs new file mode 100644 index 0000000000..89ee7a93de --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UpdatedUserGraphQLTransformer.cs @@ -0,0 +1,18 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UpdatedUserGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UpdatedUserGraphQLTransformer() + : base(model => model.User != null + ? new GraphQL.Types.UpdatedUser(model.User) + : new GraphQL.Types.UpdatedUser(model.Id)) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/UpdatedUser.cs b/src/Tgstation.Server.Host/Models/UpdatedUser.cs new file mode 100644 index 0000000000..ed7c6e2b6c --- /dev/null +++ b/src/Tgstation.Server.Host/Models/UpdatedUser.cs @@ -0,0 +1,51 @@ +using System; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models.Transformers; + +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents a that has been updated. + /// + public sealed class UpdatedUser : + ILegacyApiTransformable, + IApiTransformable + { + /// + /// The 's . + /// + public long Id { get; } + + /// + /// The , if it authorized to be read. + /// + public User? User { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of containing the . + public UpdatedUser(User user) + : this((user ?? throw new ArgumentNullException(nameof(user))).Require(u => u.Id)) + { + User = user; + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public UpdatedUser(long id) + { + Id = id; + } + + /// + public UserResponse ToApi() + => User?.ToApi() ?? new UserResponse + { + Id = Id, + }; + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 540cf72c23..ed9651f2a7 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Security.Claims; using System.Threading; @@ -12,7 +11,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api; using Tgstation.Server.Api.Rights; @@ -77,30 +75,6 @@ namespace Tgstation.Server.Host.Security /// int initialized; - /// - /// Parse a out of a in a given . - /// - /// The containing claims. - /// The name to parse from. - /// The parsed . - static DateTimeOffset ParseTime(ClaimsPrincipal principal, string key) - { - var claim = principal.FindFirst(key); - if (claim == default) - throw new InvalidOperationException($"Missing '{key}' claim!"); - - try - { - return new DateTimeOffset( - EpochTime.DateTime( - Int64.Parse(claim.Value, CultureInfo.InvariantCulture))); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to parse '{key}'!", ex); - } - } - /// /// Initializes a new instance of the class. /// @@ -148,23 +122,10 @@ namespace Tgstation.Server.Host.Security throw new InvalidOperationException("Authentication context has already been loaded"); var principal = new ClaimsPrincipal(new ClaimsIdentity(jwt.Claims)); + var userId = principal.GetTgsUserId(); - var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); - if (userIdClaim == default) - throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); - - long userId; - try - { - userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); - } - catch (Exception e) - { - throw new InvalidOperationException("Failed to parse user ID!", e); - } - - var notBefore = ParseTime(principal, JwtRegisteredClaimNames.Nbf); - var expires = ParseTime(principal, JwtRegisteredClaimNames.Exp); + var notBefore = principal.ParseTime(JwtRegisteredClaimNames.Nbf); + var expires = principal.ParseTime(JwtRegisteredClaimNames.Exp); var user = await databaseContext .Users @@ -376,7 +337,7 @@ namespace Tgstation.Server.Host.Security await databaseContext.Save(cancellationToken); } - var expires = ParseTime(principal, JwtRegisteredClaimNames.Exp); + var expires = principal.ParseTime(JwtRegisteredClaimNames.Exp); currentAuthenticationContext.Initialize( user, diff --git a/src/Tgstation.Server.Host/Security/AuthorizationHandler.cs b/src/Tgstation.Server.Host/Security/AuthorizationHandler.cs new file mode 100644 index 0000000000..94755b103b --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthorizationHandler.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security.RightsEvaluation; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Security +{ + /// + /// for s and s. + /// + public sealed class AuthorizationHandler : IAuthorizationHandler + { + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly IApiHeadersProvider apiHeadersProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorizationHandler(IDatabaseContextFactory databaseContextFactory, IApiHeadersProvider apiHeadersProvider) + { + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); + } + + /// + public Task HandleAsync(AuthorizationHandlerContext context) + { + // https://github.com/dotnet/aspnetcore/issues/56272 + CancellationToken cancellationToken = CancellationToken.None; + + ArgumentNullException.ThrowIfNull(context); + + // all the requirements we process require authentication + if (context.User.Identity?.IsAuthenticated != true) + { + context.Fail( + new AuthorizationFailureReason(this, "User is not authenticated!")); + return Task.CompletedTask; + } + + List processingRequirements = new List(); + + foreach (var req in context.Requirements.OfType()) + processingRequirements.Add( + HandleSessionValidRequirement(context, req, cancellationToken)); + + var method = GetType().GetMethod(nameof(InvokeHandleRightsConditionalRequirement), BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Failed to locate rights handler function!"); + foreach (var rightType in RightsHelper.AllRightTypes()) + { + var genericMethod = method.MakeGenericMethod(rightType); + processingRequirements.AddRange((IEnumerable)genericMethod.Invoke(this, [context, cancellationToken])!); + } + + return ValueTaskExtensions.WhenAll(processingRequirements).AsTask(); + } + + /// + /// Handle invoking for given . + /// + /// The of right to invoke the requirement handler for. + /// The shared . + /// The for the operation. + /// An of s representing the running operation. + IEnumerable InvokeHandleRightsConditionalRequirement(AuthorizationHandlerContext context, CancellationToken cancellationToken) + where TRights : Enum + => context.Requirements.OfType>().Select(requirement => HandleRightsConditionalRequirement(context, requirement, cancellationToken)); + + /// + /// Handle authorization requirements. + /// + /// The shared . + /// The requirment to evaluate. + /// The for the operation. + /// A representing the running operation. + ValueTask HandleSessionValidRequirement(AuthorizationHandlerContext context, UserSessionValidRequirement requirement, CancellationToken cancellationToken) + { + var userId = context.User.GetTgsUserId(); + + var nbf = context.User.ParseTime(JwtRegisteredClaimNames.Nbf); + + return databaseContextFactory.UseContext(async databaseContext => + { + var sessionData = await databaseContext + .Users + .AsQueryable() + .Where(user => user.Id == userId) + .Select(user => new + { + Enabled = user.Enabled!.Value, + user.LastPasswordUpdate, + }) + .TagWith("user_session_validation") + .FirstOrDefaultAsync(cancellationToken); + + lock (context) + { + if (sessionData == null) + context.Fail( + new AuthorizationFailureReason(this, $"Unable to retrieve user {userId}!")); + else if (!sessionData.Enabled) + context.Fail( + new AuthorizationFailureReason(this, "User is disabled!")); + else if (sessionData.LastPasswordUpdate >= nbf) + context.Fail( + new AuthorizationFailureReason(this, "User has been modified since logging in!")); + else + context.Succeed(requirement); + } + }); + } + + /// + /// Handle authorization requirements. + /// + /// The of right to invoke the requirement handler for. + /// The shared . + /// The requirment to evaluate. + /// The for the operation. + /// A representing the running operation. + ValueTask HandleRightsConditionalRequirement(AuthorizationHandlerContext context, RightsConditional requirement, CancellationToken cancellationToken) + where TRights : Enum + { + var rightsType = RightsHelper.TypeToRight(); + var isInstance = RightsHelper.IsInstanceRight(rightsType); + var userId = context.User.GetTgsUserId(); + + return databaseContextFactory.UseContext(async databaseContext => + { + var queryableUsers = databaseContext + .Users + .AsQueryable(); + + var matchingUniquePermissionSetIds = queryableUsers + .Where(user => user.Id == userId && user.PermissionSet != null) + .Select(user => user.PermissionSet!.Id); + + var matchingGroupPermissionSetIds = queryableUsers + .Where(user => user.Id == userId && user.Group != null) + .Select(user => user.Group!.PermissionSet!.Id); + + object? permissionSet; + if (isInstance) + { + if (context.Resource is not Instance instance) + throw new InvalidOperationException("Instance should have been passed in as authorization resource!"); + + var instanceId = instance.Require(i => i.Id); + + permissionSet = await databaseContext + .InstancePermissionSets + .AsQueryable() + .Where(ips => ips.InstanceId == instanceId + && (matchingUniquePermissionSetIds.Contains(ips.PermissionSetId) || matchingGroupPermissionSetIds.Contains(ips.PermissionSetId))) + .TagWith("rights_authorization_handler_instance_permission_set") + .FirstOrDefaultAsync(cancellationToken); + } + else + permissionSet = await databaseContext + .PermissionSets + .AsQueryable() + .Where(permissionSet => matchingUniquePermissionSetIds.Contains(permissionSet.Id) || matchingGroupPermissionSetIds.Contains(permissionSet.Id)) + .TagWith("rights_authorization_handler_permission_set") + .FirstOrDefaultAsync(cancellationToken); + + if (permissionSet == null) + { + context.Fail( + new AuthorizationFailureReason(this, $"Unable to find {(isInstance ? "instance " : String.Empty)}permission set for user.")); + return; + } + + // use the api versions because they're the ones that contain the actual properties + var requiredPermissionSetType = isInstance ? typeof(InstancePermissionSet) : typeof(PermissionSet); + + var rightsClrType = typeof(TRights); + var nullableRightsType = typeof(Nullable<>).MakeGenericType(rightsClrType); + + var rightPropertyInfo = requiredPermissionSetType + .GetProperties() + .Where(propertyInfo => propertyInfo.PropertyType == nullableRightsType && propertyInfo.CanRead) + .Single(); + + var rightPropertyGetMethod = rightPropertyInfo.GetMethod; + if (rightPropertyGetMethod == null) + throw new InvalidOperationException($"Rights property {rightPropertyInfo.Name} on {rightsClrType.FullName} has no getter!"); + + var right = rightPropertyGetMethod.Invoke( + permissionSet, + Array.Empty()) + ?? throw new InvalidOperationException("A user right was null!"); + + var result = requirement.Evaluate((TRights)right); + + lock (context) + { + if (result) + context.Succeed(requirement); + else + context.Fail( + new AuthorizationFailureReason(this, $"Failed to successfully evaluate rights requirement: {requirement}")); + } + }); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthorizationService.cs b/src/Tgstation.Server.Host/Security/AuthorizationService.cs new file mode 100644 index 0000000000..5b966cbd57 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthorizationService.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class AuthorizationService : IAuthorizationService + { + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + + /// + /// The for the . + /// + readonly Microsoft.AspNetCore.Authorization.IAuthorizationService aspNetCoreAuthorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorizationService( + IClaimsPrincipalAccessor claimsPrincipalAccessor, + Microsoft.AspNetCore.Authorization.IAuthorizationService aspNetCoreAuthorizationService) + { + this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor)); + this.aspNetCoreAuthorizationService = aspNetCoreAuthorizationService ?? throw new ArgumentNullException(nameof(aspNetCoreAuthorizationService)); + } + + /// + public async ValueTask AuthorizeAsync(IEnumerable requirements) + { + ArgumentNullException.ThrowIfNull(requirements); + + // asp net fails for an empty authorization requirement list + var bakedRequirements = requirements.ToList(); + if (bakedRequirements.Count == 0) + return AuthorizationResult.Success(); + + var result = await aspNetCoreAuthorizationService.AuthorizeAsync( + claimsPrincipalAccessor.User, + null, + bakedRequirements); + + return result; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs b/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs new file mode 100644 index 0000000000..ae20f8b7ca --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs @@ -0,0 +1,30 @@ +using System; +using System.Security.Claims; + +using Microsoft.AspNetCore.Http; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class ClaimsPrincipalAccessor : IClaimsPrincipalAccessor + { + /// + public ClaimsPrincipal User => httpContextAccessor.HttpContext?.User + ?? throw new InvalidOperationException("HTTP context was not present!"); + + /// + /// The for the . + /// + readonly IHttpContextAccessor httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public ClaimsPrincipalAccessor( + IHttpContextAccessor httpContextAccessor) + { + this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/IAuthorizationService.cs b/src/Tgstation.Server.Host/Security/IAuthorizationService.cs new file mode 100644 index 0000000000..044c5a1df4 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IAuthorizationService.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Interface for evaluating s. + /// + public interface IAuthorizationService + { + /// + /// Attempt to authorize the current context with a given . + /// + /// The to authorize. + /// A resulting in the . + ValueTask AuthorizeAsync(IEnumerable requirement); + } +} diff --git a/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs b/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs new file mode 100644 index 0000000000..9ac3da2494 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs @@ -0,0 +1,15 @@ +using System.Security.Claims; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Interface for accessing the current request's . + /// + interface IClaimsPrincipalAccessor + { + /// + /// Get the current . + /// + ClaimsPrincipal User { get; } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs index 3195ec9dcf..c11a1ba991 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs @@ -1,9 +1,9 @@ using System; +using System.Net.Http; using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Host.Security.OAuth @@ -25,11 +25,11 @@ namespace Tgstation.Server.Host.Security.OAuth /// /// Initializes a new instance of the class. /// - /// The for the . + /// The for the . /// The for the . /// The for the . public DiscordOAuthValidator( - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, ILogger logger, OAuthConfiguration oAuthConfiguration) : base(httpClientFactory, logger, oAuthConfiguration) diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs index b68813be3c..eb5570c634 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -13,7 +13,6 @@ using Newtonsoft.Json.Serialization; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Extensions; @@ -53,7 +52,7 @@ namespace Tgstation.Server.Host.Security.OAuth /// /// The for the . /// - readonly IAbstractHttpClientFactory httpClientFactory; + readonly IHttpClientFactory httpClientFactory; /// /// Gets that should be used. @@ -74,7 +73,7 @@ namespace Tgstation.Server.Host.Security.OAuth /// The value of . /// The value of . public GenericOAuthValidator( - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, ILogger logger, OAuthConfiguration oAuthConfiguration) { @@ -178,10 +177,10 @@ namespace Tgstation.Server.Host.Security.OAuth protected abstract OAuthTokenRequest CreateTokenRequest(string code); /// - /// Create a new configured . + /// Create a new configured . /// - /// A new configured . - IHttpClient CreateHttpClient() + /// A new configured . + HttpClient CreateHttpClient() { var httpClient = httpClientFactory.CreateClient(); try diff --git a/src/Tgstation.Server.Host/Security/OAuth/InvisionCommunityOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/InvisionCommunityOAuthValidator.cs index 20206e86d8..75733d0c51 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/InvisionCommunityOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/InvisionCommunityOAuthValidator.cs @@ -1,9 +1,9 @@ using System; +using System.Net.Http; using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Host.Security.OAuth @@ -27,11 +27,11 @@ namespace Tgstation.Server.Host.Security.OAuth /// /// Initializes a new instance of the class. /// - /// The for the . + /// The for the . /// The for the . /// The for the . public InvisionCommunityOAuthValidator( - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, ILogger logger, OAuthConfiguration oAuthConfiguration) : base(httpClientFactory, logger, oAuthConfiguration) diff --git a/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs index 795812b40d..ee9efcd214 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs @@ -1,9 +1,9 @@ using System; +using System.Net.Http; using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Host.Security.OAuth @@ -32,11 +32,11 @@ namespace Tgstation.Server.Host.Security.OAuth /// /// Initializes a new instance of the class. /// - /// The for the . + /// The for the . /// The for the . /// The for the . public KeycloakOAuthValidator( - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, ILogger logger, OAuthConfiguration oAuthConfiguration) : base(httpClientFactory, logger, oAuthConfiguration) diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs index 1f67b1999f..f514094e01 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tgstation.Server.Api.Models; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Utils.GitHub; @@ -24,12 +24,12 @@ namespace Tgstation.Server.Host.Security.OAuth /// Initializes a new instance of the class. /// /// The to use. - /// The to use. + /// The to use. /// The to use. /// The containing the to use. public OAuthProviders( IGitHubServiceFactory gitHubServiceFactory, - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, IOptions securityConfigurationOptions) { diff --git a/src/Tgstation.Server.Host/Security/RightsEvaluation/AndRightsConditional{TRights}.cs b/src/Tgstation.Server.Host/Security/RightsEvaluation/AndRightsConditional{TRights}.cs new file mode 100644 index 0000000000..4f39f1978d --- /dev/null +++ b/src/Tgstation.Server.Host/Security/RightsEvaluation/AndRightsConditional{TRights}.cs @@ -0,0 +1,41 @@ +using System; + +namespace Tgstation.Server.Host.Security.RightsEvaluation +{ + /// + /// Logical AND . + /// + /// The to evaluate. + public sealed class AndRightsConditional : RightsConditional + where TRights : Enum + { + /// + /// The left hand side operand. + /// + readonly RightsConditional lhs; + + /// + /// The right hand side operand. + /// + readonly RightsConditional rhs; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AndRightsConditional(RightsConditional lhs, RightsConditional rhs) + { + this.lhs = lhs ?? throw new ArgumentNullException(nameof(lhs)); + this.rhs = rhs ?? throw new ArgumentNullException(nameof(rhs)); + } + + /// + public override bool Evaluate(TRights rights) + => lhs.Evaluate(rights) && rhs.Evaluate(rights); + + /// + public override string ToString() + => $"({lhs} && {rhs})"; + } +} diff --git a/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs b/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs new file mode 100644 index 0000000000..9a2ecae4db --- /dev/null +++ b/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs @@ -0,0 +1,43 @@ +using System; + +namespace Tgstation.Server.Host.Security.RightsEvaluation +{ + /// + /// Single flag . + /// + /// The to evaluate. + public sealed class FlagRightsConditional : RightsConditional + where TRights : Enum + { + /// + /// The single bit flag of the . + /// + readonly TRights flag; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public FlagRightsConditional(TRights flag) + { + var asUlong = (ulong)(object)flag; + + if (asUlong == 0) + throw new ArgumentOutOfRangeException(nameof(flag), flag, "Flag cannot be zero!"); + + // https://stackoverflow.com/a/28303898/3976486 + if ((asUlong & (asUlong - 1)) != 0) + throw new ArgumentException("Right has more than one bit set!", nameof(flag)); + + this.flag = flag; + } + + /// + public override bool Evaluate(TRights rights) + => rights.HasFlag(flag); + + /// + public override string ToString() + => $"{typeof(TRights).Name}.{flag}"; + } +} diff --git a/src/Tgstation.Server.Host/Security/RightsEvaluation/OrRightsConditional{TRights}.cs b/src/Tgstation.Server.Host/Security/RightsEvaluation/OrRightsConditional{TRights}.cs new file mode 100644 index 0000000000..5e436915d7 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/RightsEvaluation/OrRightsConditional{TRights}.cs @@ -0,0 +1,41 @@ +using System; + +namespace Tgstation.Server.Host.Security.RightsEvaluation +{ + /// + /// Logical OR . + /// + /// The to evaluate. + public sealed class OrRightsConditional : RightsConditional + where TRights : Enum + { + /// + /// The left hand side operand. + /// + readonly RightsConditional lhs; + + /// + /// The right hand side operand. + /// + readonly RightsConditional rhs; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public OrRightsConditional(RightsConditional lhs, RightsConditional rhs) + { + this.lhs = lhs ?? throw new ArgumentNullException(nameof(lhs)); + this.rhs = rhs ?? throw new ArgumentNullException(nameof(rhs)); + } + + /// + public override bool Evaluate(TRights rights) + => lhs.Evaluate(rights) || rhs.Evaluate(rights); + + /// + public override string ToString() + => $"({lhs} || {rhs})"; + } +} diff --git a/src/Tgstation.Server.Host/Security/RightsEvaluation/RightsConditional{TRights}.cs b/src/Tgstation.Server.Host/Security/RightsEvaluation/RightsConditional{TRights}.cs new file mode 100644 index 0000000000..62fe39a730 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/RightsEvaluation/RightsConditional{TRights}.cs @@ -0,0 +1,21 @@ +using System; + +using Microsoft.AspNetCore.Authorization; + +namespace Tgstation.Server.Host.Security.RightsEvaluation +{ + /// + /// An conditional expression of . + /// + /// The to evaluate. + public abstract class RightsConditional : IAuthorizationRequirement + where TRights : Enum + { + /// + /// Test if the is satified for the given . + /// + /// The to evaluate the conditional for. + /// if the is satisfied by the given . + public abstract bool Evaluate(TRights rights); + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs deleted file mode 100644 index 2bd0bfac52..0000000000 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -using HotChocolate.Authorization; - -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Host.Security -{ - /// - /// Helper for using the with the system. - /// -#pragma warning disable CA1019 - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] - sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute - { - /// - /// Gets the associated with the if any. - /// - public RightsType? RightsType { get; } - - /// - /// Initializes a new instance of the class. - /// - public TgsGraphQLAuthorizeAttribute() - : this(Enumerable.Empty()) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Administration; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.InstanceManager; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Repository; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Engine; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.DreamMaker; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.DreamDaemon; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.ChatBots; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Configuration; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.InstancePermissionSet; - } - - /// - /// Initializes a new instance of the class. - /// - /// of role names. - private TgsGraphQLAuthorizeAttribute(IEnumerable roleNames) - { - var listRoles = roleNames.ToList(); - if (listRoles.Count != 0) - { - Roles = [.. listRoles]; - } - - Policy = TgsAuthorizeAttribute.PolicyName; - Apply = ApplyPolicy.Validation; - } - } -} diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs deleted file mode 100644 index 495424073a..0000000000 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Reflection; - -using HotChocolate.Authorization; - -using Tgstation.Server.Host.Authority.Core; - -namespace Tgstation.Server.Host.Security -{ - /// - /// Inherits the roles of s for GraphQL endpoints. - /// - /// The being wrapped. - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute - where TAuthority : IAuthority - { - /// - /// The name of the method targeted. - /// - public string MethodName { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The method name to inherit roles from. - public TgsGraphQLAuthorizeAttribute(string methodName) - { - ArgumentNullException.ThrowIfNull(methodName); - - var authorityType = typeof(TAuthority); - var authorityMethod = authorityType.GetMethod(methodName) - ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); - var authorizeAttribute = authorityMethod.GetCustomAttribute() - ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); - MethodName = methodName; - Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries); - Apply = ApplyPolicy.Validation; - } - } -} diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs deleted file mode 100644 index 6103eca33b..0000000000 --- a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Reflection; - -using Microsoft.AspNetCore.Authorization; - -using Tgstation.Server.Host.Authority.Core; - -namespace Tgstation.Server.Host.Security -{ - /// - /// Inherits the roles of s for REST endpoints. - /// - /// The being wrapped. - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute - where TAuthority : IAuthority - { - /// - /// The name of the method targeted. - /// - public string MethodName { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The method name to inherit roles from. - public TgsRestAuthorizeAttribute(string methodName) - { - ArgumentNullException.ThrowIfNull(methodName); - - var authorityType = typeof(TAuthority); - var authorityMethod = authorityType.GetMethod(methodName) - ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); - - var authorizeAttribute = authorityMethod.GetCustomAttribute() - ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); - - MethodName = methodName; - Roles = authorizeAttribute.Roles; - } - } -} diff --git a/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs b/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs new file mode 100644 index 0000000000..79a49a9564 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +using Microsoft.AspNetCore.Authorization; + +namespace Tgstation.Server.Host.Security +{ + /// + /// for testing if a user is enabled and their session is valid. + /// + sealed class UserSessionValidRequirement : IAuthorizationRequirement + { + /// + /// The singleton instance of this class. + /// + public static IEnumerable InstanceAsEnumerable { get; } = + [ + new(), + ]; + + /// + /// Initializes a new instance of the class. + /// + private UserSessionValidRequirement() + { + } + } +} diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 640b0c8517..6f4a10c7d4 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -43,6 +43,11 @@ namespace Tgstation.Server.Host /// internal IHost? Host { get; private set; } + /// + /// The to use. + /// + readonly IIOManager ioManager; + /// /// The for the . /// @@ -101,10 +106,12 @@ namespace Tgstation.Server.Host /// /// Initializes a new instance of the class. /// + /// The value of . /// The value of . /// The value of . - public Server(IHostBuilder hostBuilder, string? updatePath) + public Server(IIOManager ioManager, IHostBuilder hostBuilder, string? updatePath) { + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.hostBuilder = hostBuilder ?? throw new ArgumentNullException(nameof(hostBuilder)); this.updatePath = updatePath; @@ -118,7 +125,7 @@ namespace Tgstation.Server.Host /// public async ValueTask Run(CancellationToken cancellationToken) { - var updateDirectory = updatePath != null ? Path.GetDirectoryName(updatePath) : null; + var updateDirectory = updatePath != null ? ioManager.GetDirectoryName(updatePath) : null; using (cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) using (var fsWatcher = updateDirectory != null ? new FileSystemWatcher(updateDirectory) : null) { @@ -421,12 +428,12 @@ namespace Tgstation.Server.Host /// /// The that sent the event. /// The . - void WatchForShutdownFileCreation(object sender, FileSystemEventArgs eventArgs) + async void WatchForShutdownFileCreation(object sender, FileSystemEventArgs eventArgs) { logger?.LogTrace("FileSystemWatcher triggered."); - // TODO: Refactor this to not use System.IO function here. - if (eventArgs.FullPath == Path.GetFullPath(updatePath!) && File.Exists(eventArgs.FullPath)) + // DCT: None available + if (eventArgs.FullPath == ioManager.ResolvePath(updatePath!) && await ioManager.FileExists(eventArgs.FullPath, CancellationToken.None)) { logger?.LogInformation("Host watchdog appears to be requesting server termination!"); lock (restartLock) diff --git a/src/Tgstation.Server.Host/ServerFactory.cs b/src/Tgstation.Server.Host/ServerFactory.cs index 06f1cbc763..20e8786f2c 100644 --- a/src/Tgstation.Server.Host/ServerFactory.cs +++ b/src/Tgstation.Server.Host/ServerFactory.cs @@ -205,7 +205,7 @@ namespace Tgstation.Server.Host IOManager.ResolvePath( IOManager.GetDirectoryName(assemblyInformationProvider.Path))); - return new Server(hostBuilder, updatePath); + return new Server(IOManager, hostBuilder, updatePath); } #pragma warning restore CA1506 } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index b2bfb1ca6e..c9fbe62f90 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -19,7 +19,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Extensions; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Core; @@ -76,9 +75,9 @@ namespace Tgstation.Server.Host.Swarm readonly IAssemblyInformationProvider assemblyInformationProvider; /// - /// The for the . + /// The for the . /// - readonly IAbstractHttpClientFactory httpClientFactory; + readonly IHttpClientFactory httpClientFactory; /// /// The for the . @@ -193,7 +192,7 @@ namespace Tgstation.Server.Host.Swarm IDatabaseContextFactory databaseContextFactory, IDatabaseSeeder databaseSeeder, IAssemblyInformationProvider assemblyInformationProvider, - IAbstractHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IAsyncDelayer asyncDelayer, IServerUpdater serverUpdater, IFileTransferTicketProvider transferService, diff --git a/src/Tgstation.Server.Host/Utils/AbstractHttpClientFactory.cs b/src/Tgstation.Server.Host/Utils/AbstractHttpClientFactory.cs deleted file mode 100644 index 973551e1d4..0000000000 --- a/src/Tgstation.Server.Host/Utils/AbstractHttpClientFactory.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Net.Http; - -using Microsoft.Extensions.Logging; - -using Tgstation.Server.Common.Http; -using Tgstation.Server.Host.System; - -namespace Tgstation.Server.Host.Utils -{ - /// - sealed class AbstractHttpClientFactory : IAbstractHttpClientFactory - { - /// - /// The real . - /// - readonly IHttpClientFactory httpClientFactory; - - /// - /// The for the . - /// - readonly IAssemblyInformationProvider assemblyInformationProvider; - - /// - /// The for the . - /// - readonly ILogger logger; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - /// The value of . - /// The value of . - public AbstractHttpClientFactory( - IHttpClientFactory httpClientFactory, - IAssemblyInformationProvider assemblyInformationProvider, - ILogger logger) - { - this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// -#pragma warning disable IDE0079 -#pragma warning disable CA2000 - public IHttpClient CreateClient() - { - logger.LogTrace("Creating client..."); - var innerClient = httpClientFactory.CreateClient(); - try - { - var client = new Tgstation.Server.Common.Http.HttpClient(innerClient); - innerClient = null; - try - { - client.DefaultRequestHeaders.UserAgent.Add(assemblyInformationProvider.ProductInfoHeaderValue); - return client; - } - catch - { - client.Dispose(); - throw; - } - } - catch - { - innerClient?.Dispose(); - throw; - } - } -#pragma warning restore CA2000 -#pragma warning restore IDE0079 - } -} diff --git a/tests/DMAPI/BasicOperation/Test.dm b/tests/DMAPI/BasicOperation/Test.dm index 30b02a4bee..128f03f750 100644 --- a/tests/DMAPI/BasicOperation/Test.dm +++ b/tests/DMAPI/BasicOperation/Test.dm @@ -39,15 +39,16 @@ fdel("test_event_output.txt") var/test_data = "nwfiuurhfu" world.TgsTriggerEvent("test_event", list(test_data), TRUE) - if(!fexists("test_event_output.txt")) - FailTest("Expected test_event_output.txt to exist here", "test_fail_reason.txt") + if(world.TgsAvailable()) + if(!fexists("test_event_output.txt")) + FailTest("Expected test_event_output.txt to exist here", "test_fail_reason.txt") - var/test_contents = copytext(file2text("test_event_output.txt"), 1, length(test_data) + 1) - if(test_contents != test_data) - FailTest("Expected test_event_output.txt to contain [test_data] here. Got [test_contents]", "test_fail_reason.txt") + var/test_contents = copytext(file2text("test_event_output.txt"), 1, length(test_data) + 1) + if(test_contents != test_data) + FailTest("Expected test_event_output.txt to contain [test_data] here. Got [test_contents]", "test_fail_reason.txt") - world.log << "file check 1" - fdel("test_event_output.txt") + world.log << "file check 1" + fdel("test_event_output.txt") var/start_time = world.timeofday world.TgsTriggerEvent("test_event", list("asdf"), FALSE) @@ -59,8 +60,11 @@ sleep(150) world.log << "Terminating..." world.TgsEndProcess() + if(world.TgsAvailable()) + FailTest("Expected TGS to not let us reach this point") - world.log << "You really shouldn't be able to read this" + del(world) + sleep(1) /world/Export(url) log << "Export: [url]" diff --git a/tests/Tgstation.Server.Api.Tests/Models/Internal/TestEngineVersion.cs b/tests/Tgstation.Server.Api.Tests/Models/Internal/TestEngineVersion.cs index f13f09f3ff..cda2983b53 100644 --- a/tests/Tgstation.Server.Api.Tests/Models/Internal/TestEngineVersion.cs +++ b/tests/Tgstation.Server.Api.Tests/Models/Internal/TestEngineVersion.cs @@ -40,7 +40,7 @@ namespace Tgstation.Server.Api.Models.Internal.Tests Assert.IsFalse(EngineVersion.TryParse("x", out version)); Assert.IsNull(version); - Assert.ThrowsException(() => EngineVersion.Parse("x")); + Assert.ThrowsExactly(() => EngineVersion.Parse("x")); } } } diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 8d6808f1b2..a0cf7fc181 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -21,8 +21,8 @@ namespace Tgstation.Server.Api.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new ApiHeaders(null, null)); - Assert.ThrowsException(() => new ApiHeaders(productHeaderValue, null)); + Assert.ThrowsExactly(() => new ApiHeaders(null, null)); + Assert.ThrowsExactly(() => new ApiHeaders(productHeaderValue, null)); var headers = new ApiHeaders(productHeaderValue, new TokenResponse { Bearer = String.Empty }); headers = new ApiHeaders(productHeaderValue, String.Empty, OAuthProvider.GitHub); } @@ -54,7 +54,7 @@ namespace Tgstation.Server.Api.Tests Assert.AreEqual(ConformantHeader, header.RawUserAgent); Assert.IsNotNull(header.UserAgent); - Assert.ThrowsException(() => TestHeader(String.Empty)); + Assert.ThrowsExactly(() => TestHeader(String.Empty)); } } } diff --git a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs index 900d64a56f..ca18f42d63 100644 --- a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs +++ b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs @@ -15,6 +15,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Http; +using Tgstation.Server.Common.Tests; namespace Tgstation.Server.Client.Tests { @@ -44,11 +45,10 @@ namespace Tgstation.Server.Client.Tests Content = new StringContent(sampleJson) }; - var httpClient = new Mock(); - httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(response)); + var handler = new MockHttpMessageHandler((_, __) => Task.FromResult(response)); var client = new ApiClient( - httpClient.Object, + new HttpClient(handler), new Uri("http://fake.com"), new ApiHeaders( new ProductHeaderValue("fake"), @@ -84,11 +84,10 @@ namespace Tgstation.Server.Client.Tests Content = new StringContent(fakeJson) }; - var httpClient = new Mock(); - httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(response)); + var handler = new MockHttpMessageHandler((_, __) => Task.FromResult(response)); var client = new ApiClient( - httpClient.Object, + new HttpClient(handler), new Uri("http://fake.com"), new ApiHeaders( new ProductHeaderValue("fake"), @@ -99,7 +98,7 @@ namespace Tgstation.Server.Client.Tests null, false); - await Assert.ThrowsExceptionAsync(() => client.Read(Routes.Engine, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => client.Read(Routes.Engine, default).AsTask()); } } } diff --git a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs b/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs index c9af0484da..97023d7b62 100644 --- a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs +++ b/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs @@ -10,7 +10,7 @@ namespace Tgstation.Server.Client.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new RestServerClientFactory(null)); + Assert.ThrowsExactly(() => new RestServerClientFactory(null)); new RestServerClientFactory(new ProductHeaderValue("Tgstation.Server.Client.Tests", GetType().Assembly.GetName().Version.ToString())); } } diff --git a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj index 30f44b73f0..07849c199b 100644 --- a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj +++ b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/Tgstation.Server.Common.Tests/Extensions/TestVersionExtensions.cs b/tests/Tgstation.Server.Common.Tests/Extensions/TestVersionExtensions.cs new file mode 100644 index 0000000000..d5f7fee277 --- /dev/null +++ b/tests/Tgstation.Server.Common.Tests/Extensions/TestVersionExtensions.cs @@ -0,0 +1,19 @@ +using System; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Tgstation.Server.Common.Extensions.Tests +{ + /// + /// Tests for . + /// + [TestClass] + public sealed class TestVersionExtensions + { + [TestMethod] + public void TestSemver() + { + Assert.AreEqual(new Version(1, 2, 3), new Version(1, 2, 3, 4).Semver()); + } + } +} diff --git a/tests/Tgstation.Server.Common.Tests/MockHttpMessageHandler.cs b/tests/Tgstation.Server.Common.Tests/MockHttpMessageHandler.cs new file mode 100644 index 0000000000..e4fc02dd89 --- /dev/null +++ b/tests/Tgstation.Server.Common.Tests/MockHttpMessageHandler.cs @@ -0,0 +1,23 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Common.Tests +{ + /// + /// Simple mock . + /// + public sealed class MockHttpMessageHandler : HttpMessageHandler + { + readonly Func> callback; + + public MockHttpMessageHandler(Func> callback) + { + this.callback = callback ?? throw new ArgumentNullException(nameof(callback)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => callback(request, cancellationToken); + } +} diff --git a/tests/Tgstation.Server.Common.Tests/Tgstation.Server.Common.Tests.csproj b/tests/Tgstation.Server.Common.Tests/Tgstation.Server.Common.Tests.csproj new file mode 100644 index 0000000000..e915d283b0 --- /dev/null +++ b/tests/Tgstation.Server.Common.Tests/Tgstation.Server.Common.Tests.csproj @@ -0,0 +1,12 @@ + + + + + $(TgsFrameworkVersion) + + + + + + + diff --git a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs index 6faf32f95e..602e50e72d 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs +++ b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs @@ -19,9 +19,9 @@ namespace Tgstation.Server.Host.Service.Tests [TestMethod] public void TestConstructionAndDisposal() { - Assert.ThrowsException(() => new ServerService(null, null, default)); + Assert.ThrowsExactly(() => new ServerService(null, null, default)); var mockWatchdogFactory = new Mock(); - Assert.ThrowsException(() => new ServerService(mockWatchdogFactory.Object, null, default)); + Assert.ThrowsExactly(() => new ServerService(mockWatchdogFactory.Object, null, default)); new ServerService(mockWatchdogFactory.Object, Array.Empty(), default).Dispose(); } diff --git a/tests/Tgstation.Server.Host.Tests.Signals/Program.cs b/tests/Tgstation.Server.Host.Tests.Signals/Program.cs index bcb5aa6699..7c4ba7d972 100644 --- a/tests/Tgstation.Server.Host.Tests.Signals/Program.cs +++ b/tests/Tgstation.Server.Host.Tests.Signals/Program.cs @@ -34,7 +34,7 @@ namespace Tgstation.Server.Host.Tests.Signals Assert.IsFalse(tcs.Task.IsCompleted); - await Assert.ThrowsExceptionAsync(() => signalHandler.StartAsync(default)); + await Assert.ThrowsExactlyAsync(() => signalHandler.StartAsync(default)); Assert.IsFalse(tcs.Task.IsCompleted); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs index 58c63c6a99..deb77fd538 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs @@ -56,15 +56,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Instance = new Models.Instance(), }; - Assert.ThrowsException(() => new DiscordProvider(null, null, null, null, null, null)); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, null, null, null, null)); + Assert.ThrowsExactly(() => new DiscordProvider(null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new DiscordProvider(mockJobManager, null, null, null, null, null)); var mockDel = Mock.Of(); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, null, null, null, null)); + Assert.ThrowsExactly(() => new DiscordProvider(mockJobManager, mockDel, null, null, null, null)); var mockLogger = Mock.Of>(); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, null, null, null)); + Assert.ThrowsExactly(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, null, null, null)); var mockAss = Mock.Of(); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, null, null)); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot, null)); + Assert.ThrowsExactly(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, null, null)); + Assert.ThrowsExactly(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot, null)); var mockGen = new GeneralConfiguration(); await new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot, mockGen).DisposeAsync(); } @@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests ConnectionString = "asdf", Instance = new Models.Instance(), }, new GeneralConfiguration()); - await Assert.ThrowsExceptionAsync(async () => await InvokeConnect(provider)); + await Assert.ThrowsExactlyAsync(async () => await InvokeConnect(provider)); Assert.IsFalse(provider.Connected); } diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs index b39f84f4c0..83b2e7956f 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs @@ -23,15 +23,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests [TestMethod] public async Task TestConstructionAndDisposal() { - Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new IrcProvider(null, null, null, null, null, null)); var mockJobManager = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null, null)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, null, null, null, null, null)); var mockAsyncDelayer = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null, null)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null, null)); var mockLogger = new Mock>(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null, null)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null, null)); var mockAss = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null, null)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null, null)); var mockBot = new ChatBot { @@ -39,10 +39,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Instance = new Models.Instance(), Provider = ChatProvider.Irc }; - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, null)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, null)); var mockLogConf = new FileLoggingConfiguration(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf)); + Assert.ThrowsExactly(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf)); mockBot.ConnectionString = new IrcConnectionStringBuilder { diff --git a/tests/Tgstation.Server.Host.Tests/Components/Engine/TestOpenDreamInstaller.cs b/tests/Tgstation.Server.Host.Tests/Components/Engine/TestOpenDreamInstaller.cs index 8950acc6ab..e2ded03c84 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Engine/TestOpenDreamInstaller.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Engine/TestOpenDreamInstaller.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -8,8 +9,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; @@ -76,7 +75,7 @@ namespace Tgstation.Server.Host.Components.Engine.Tests Mock.Of(), mockRepositoryManager.Object, Mock.Of(), - Mock.Of(), + Mock.Of(), mockGeneralConfigOptions.Object, mockSessionConfigOptions.Object); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Engine/TestPosixByondInstaller.cs b/tests/Tgstation.Server.Host.Tests/Components/Engine/TestPosixByondInstaller.cs index 6dbfb155b4..3a45b4edf9 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Engine/TestPosixByondInstaller.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Engine/TestPosixByondInstaller.cs @@ -1,15 +1,18 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using System; +using System; using System.IO; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; @@ -21,15 +24,15 @@ namespace Tgstation.Server.Host.Components.Engine.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new PosixByondInstaller(null, null, null, null, null)); + Assert.ThrowsExactly(() => new PosixByondInstaller(null, null, null, null, null)); var mockPostWriteHandler = new Mock(); - Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null, null, null)); + Assert.ThrowsExactly(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null, null, null)); var mockIOManager = new Mock(); - Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null, null, null)); + Assert.ThrowsExactly(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null, null, null)); var mockFileDownloader = Mock.Of(); - Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, null, null)); + Assert.ThrowsExactly(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, null, null)); var mockOptions = Mock.Of>(); - Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, null)); + Assert.ThrowsExactly(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, null)); var mockLogger = new Mock>(); _ = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, mockLogger.Object); @@ -64,7 +67,7 @@ namespace Tgstation.Server.Host.Components.Engine.Tests var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader.Object, mockOptions.Object, mockLogger.Object); - await Assert.ThrowsExceptionAsync(() => installer.DownloadVersion(null, null, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => installer.DownloadVersion(null, null, default).AsTask()); var ourArray = Array.Empty(); mockFileDownloader @@ -96,6 +99,10 @@ namespace Tgstation.Server.Host.Components.Engine.Tests public async Task TestInstallByond() { var mockIOManager = new Mock(); + mockIOManager.Setup(x => x.FileExists(It.IsNotNull(), It.IsAny())).ReturnsAsync(true); + mockIOManager.Setup(x => x.CreateResolverForSubdirectory(It.IsNotNull())).Returns(mockIOManager.Object); + mockIOManager.Setup(x => x.ConcatPath(It.IsNotNull())).Returns(Path.Combine); + mockIOManager.Setup(x => x.ResolvePath(It.IsNotNull())).Returns(path => path); var mockPostWriteHandler = new Mock(); var mockLogger = new Mock>(); var mockFileDownloader = Mock.Of(); @@ -103,7 +110,7 @@ namespace Tgstation.Server.Host.Components.Engine.Tests var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, mockLogger.Object); const string FakePath = "fake"; - await Assert.ThrowsExceptionAsync(() => installer.Install(null, null, false, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => installer.Install(null, null, false, default).AsTask()); var byondVersion = new EngineVersion { @@ -111,7 +118,7 @@ namespace Tgstation.Server.Host.Components.Engine.Tests Version = new Version(123, 252345), }; - await Assert.ThrowsExceptionAsync(() => installer.Install(byondVersion, null, false, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => installer.Install(byondVersion, null, false, default).AsTask()); byondVersion.Version = new Version(511, 1385); await installer.Install(byondVersion, FakePath, false, default); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Events/TestEventScriptAttribute.cs b/tests/Tgstation.Server.Host.Tests/Components/Events/TestEventScriptAttribute.cs index 232f8767d6..6082c2dc7b 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Events/TestEventScriptAttribute.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Events/TestEventScriptAttribute.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Host.Components.Events.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new EventScriptAttribute(null)); + Assert.ThrowsExactly(() => new EventScriptAttribute(null)); var test = new EventScriptAttribute("test1", "test2"); Assert.IsTrue(test.ScriptNames.SequenceEqual(["test1", "test2"])); } diff --git a/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs b/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs index 84aea24ef6..b080816cb1 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs @@ -23,7 +23,7 @@ namespace Tgstation.Server.Host.Components.Repository.Tests .CreateFromPath(path, default)); [TestMethod] - public void TestConstructionThrows() => Assert.ThrowsException(() => new LibGit2RepositoryFactory(null)); + public void TestConstructionThrows() => Assert.ThrowsExactly(() => new LibGit2RepositoryFactory(null)); [TestMethod] public void TestInMemoryRepoCreation() diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestServerUpdateInitiator.cs b/tests/Tgstation.Server.Host.Tests/Core/TestServerUpdateInitiator.cs index 41bde8f588..2b345fb8d0 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestServerUpdateInitiator.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestServerUpdateInitiator.cs @@ -17,8 +17,8 @@ namespace Tgstation.Server.Host.Core.Tests [TestMethod] public void TestConstructor() { - Assert.ThrowsException(() => new ServerUpdateInitiator(null, null)); - Assert.ThrowsException(() => new ServerUpdateInitiator(Mock.Of(), null)); + Assert.ThrowsExactly(() => new ServerUpdateInitiator(null, null)); + Assert.ThrowsExactly(() => new ServerUpdateInitiator(Mock.Of(), null)); _ = new ServerUpdateInitiator(Mock.Of(), Mock.Of()); } diff --git a/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseConnectionFactory.cs b/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseConnectionFactory.cs index b2964fd934..2076ba80bc 100644 --- a/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseConnectionFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseConnectionFactory.cs @@ -16,8 +16,8 @@ namespace Tgstation.Server.Host.Database.Tests public void TestBadParameters() { var factory = new DatabaseConnectionFactory(); - Assert.ThrowsException(() => factory.CreateConnection(null, default)); - Assert.ThrowsException(() => factory.CreateConnection(String.Empty, (DatabaseType)42)); + Assert.ThrowsExactly(() => factory.CreateConnection(null, default)); + Assert.ThrowsExactly(() => factory.CreateConnection(String.Empty, (DatabaseType)42)); } [TestMethod] diff --git a/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseContextFactory.cs b/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseContextFactory.cs index a0413a9158..8873270a67 100644 --- a/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseContextFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Database/TestDatabaseContextFactory.cs @@ -14,14 +14,14 @@ namespace Tgstation.Server.Host.Database.Tests [TestMethod] public void TestConstructionThrows() { - Assert.ThrowsException(() => new DatabaseContextFactory(null)); + Assert.ThrowsExactly(() => new DatabaseContextFactory(null)); var mockProvider = new Mock(); mockProvider.Setup(x => x.GetService(typeof(IDatabaseContext))).Verifiable(); var mockScope = new Mock(); mockScope.SetupGet(x => x.ServiceProvider).Returns(mockProvider.Object).Verifiable(); var mockScopeFactory = new Mock(); mockScopeFactory.Setup(x => x.CreateScope()).Returns(mockScope.Object).Verifiable(); - Assert.ThrowsException(() => new DatabaseContextFactory(mockScopeFactory.Object)); + Assert.ThrowsExactly(() => new DatabaseContextFactory(mockScopeFactory.Object)); mockScopeFactory.VerifyAll(); mockScope.VerifyAll(); mockProvider.VerifyAll(); @@ -41,7 +41,7 @@ namespace Tgstation.Server.Host.Database.Tests var factory = new DatabaseContextFactory(mockScopeFactory.Object); - await Assert.ThrowsExceptionAsync(() => factory.UseContext(null).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.UseContext(null).AsTask()); await factory.UseContext(context => { diff --git a/tests/Tgstation.Server.Host.Tests/Extensions/TestGeneralConfigurationExtensions.cs b/tests/Tgstation.Server.Host.Tests/Extensions/TestGeneralConfigurationExtensions.cs index 1f7b726a6e..86a3f39f52 100644 --- a/tests/Tgstation.Server.Host.Tests/Extensions/TestGeneralConfigurationExtensions.cs +++ b/tests/Tgstation.Server.Host.Tests/Extensions/TestGeneralConfigurationExtensions.cs @@ -10,7 +10,7 @@ namespace Tgstation.Server.Host.Extensions.Tests [TestMethod] public void TestThrowsOnNullParameter() { - Assert.ThrowsException(() => GeneralConfigurationExtensions.GetCopyDirectoryTaskThrottle(null)); + Assert.ThrowsExactly(() => GeneralConfigurationExtensions.GetCopyDirectoryTaskThrottle(null)); } } } diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs index f5878a7e2f..cf28360070 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs @@ -14,14 +14,14 @@ namespace Tgstation.Server.Host.IO.Tests [TestMethod] public void TestContructionThrows() { - Assert.ThrowsException(() => new Console(null)); + Assert.ThrowsExactly(() => new Console(null)); } [TestMethod] public async Task TestWriteLine() { var console = new Console(new PlatformIdentifier()); - await Assert.ThrowsExceptionAsync(() => console.WriteAsync(null, false, default)); + await Assert.ThrowsExactlyAsync(() => console.WriteAsync(null, false, default)); try { await console.WriteAsync(null, true, default); diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestFileDownloader.cs b/tests/Tgstation.Server.Host.Tests/IO/TestFileDownloader.cs index 768be41442..07223533e2 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestFileDownloader.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestFileDownloader.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Net; +using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -8,7 +10,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; -using Tgstation.Server.Common.Http; +using Tgstation.Server.Common.Tests; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.IO.Tests @@ -26,9 +28,9 @@ Please see the following for more context: [TestMethod] public void TestConstructor() { - Assert.ThrowsException(() => new FileDownloader(null, null)); - Assert.ThrowsException(() => new FileDownloader(Mock.Of(), null)); - _ = new FileDownloader(Mock.Of(), Mock.Of>()); + Assert.ThrowsExactly(() => new FileDownloader(null, null)); + Assert.ThrowsExactly(() => new FileDownloader(Mock.Of(), null)); + _ = new FileDownloader(Mock.Of(), Mock.Of>()); } [TestMethod] @@ -53,7 +55,7 @@ Please see the following for more context: var downloader = CreateDownloader(out var loggerFactory); using (loggerFactory) { - Assert.ThrowsException(() => downloader.DownloadFile(null, null)); + Assert.ThrowsExactly(() => downloader.DownloadFile(null, null)); } } @@ -65,11 +67,21 @@ Please see the following for more context: builder.SetMinimumLevel(LogLevel.Trace); }); + var mockHttpClientFactory = new Mock(); + var httpClient = new HttpClient( + new MockHttpMessageHandler( + (_, __) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ExpectedData), + }))); + + mockHttpClientFactory.Setup(x => x.CreateClient(String.Empty)).Returns(httpClient); + try { return new FileDownloader( - new HttpClientFactory( - new AssemblyInformationProvider().ProductInfoHeaderValue), + mockHttpClientFactory.Object, loggerFactory.CreateLogger()); } catch diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestFilesystemLinkFactory.cs b/tests/Tgstation.Server.Host.Tests/IO/TestFilesystemLinkFactory.cs index 16ab28a73c..0d061703d7 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestFilesystemLinkFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestFilesystemLinkFactory.cs @@ -47,12 +47,12 @@ namespace Tgstation.Server.Host.IO.Tests if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateHardLink(f1, f2, CancellationToken.None)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateHardLink(f1, f2, CancellationToken.None)); Assert.Inconclusive("Windows does not support hardlinks"); } - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateHardLink(null, null, CancellationToken.None)); - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateHardLink(f1, null, CancellationToken.None)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateHardLink(null, null, CancellationToken.None)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateHardLink(f1, null, CancellationToken.None)); await linkFactory.CreateHardLink(f1, f2, default); Assert.IsTrue(File.Exists(f2)); @@ -98,8 +98,8 @@ namespace Tgstation.Server.Host.IO.Tests f2 = f1 + ".linked"; File.WriteAllText(f1, Text); - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateSymbolicLink(null, null, default)); - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateSymbolicLink(f1, null, default)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateSymbolicLink(null, null, default)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateSymbolicLink(f1, null, default)); await linkFactory.CreateSymbolicLink(f1, f2, default); Assert.IsTrue(File.Exists(f2)); @@ -131,8 +131,8 @@ namespace Tgstation.Server.Host.IO.Tests var p1 = Path.Combine(f1, FileName); File.WriteAllText(p1, Text); - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateSymbolicLink(null, null, default)); - await Assert.ThrowsExceptionAsync(() => linkFactory.CreateSymbolicLink(f1, null, default)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateSymbolicLink(null, null, default)); + await Assert.ThrowsExactlyAsync(() => linkFactory.CreateSymbolicLink(f1, null, default)); await linkFactory.CreateSymbolicLink(f1, f2, default); diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestIOManager.cs b/tests/Tgstation.Server.Host.Tests/IO/TestIOManager.cs index 72be278547..069566f978 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestIOManager.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestIOManager.cs @@ -144,28 +144,28 @@ namespace Tgstation.Server.Host.IO.Tests var tempPath1 = Guid.NewGuid().ToString(); var tempPath2 = Guid.NewGuid().ToString(); - await Assert.ThrowsExceptionAsync(() => ioManager.CopyDirectory( + await Assert.ThrowsExactlyAsync(() => ioManager.CopyDirectory( null, null, null, tempPath2, throttle, default).AsTask()); - await Assert.ThrowsExceptionAsync(() => ioManager.CopyDirectory( + await Assert.ThrowsExactlyAsync(() => ioManager.CopyDirectory( null, null, tempPath1, null, throttle, default).AsTask()); - await Assert.ThrowsExceptionAsync(() => ioManager.CopyDirectory( + await Assert.ThrowsExactlyAsync(() => ioManager.CopyDirectory( null, null, null, null, throttle, default).AsTask()); - await Assert.ThrowsExceptionAsync(() => ioManager.CopyDirectory( + await Assert.ThrowsExactlyAsync(() => ioManager.CopyDirectory( null, null, tempPath1, diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestPostWriteHandler.cs b/tests/Tgstation.Server.Host.Tests/IO/TestPostWriteHandler.cs index 71d3c3fb0b..13bcc1c491 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestPostWriteHandler.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestPostWriteHandler.cs @@ -23,8 +23,8 @@ namespace Tgstation.Server.Host.IO.Tests else postWriteHandler = new PosixPostWriteHandler(Mock.Of>()); - Assert.ThrowsException(() => postWriteHandler.HandleWrite(null)); - Assert.ThrowsException(() => postWriteHandler.NeedsPostWrite(null)); + Assert.ThrowsExactly(() => postWriteHandler.HandleWrite(null)); + Assert.ThrowsExactly(() => postWriteHandler.NeedsPostWrite(null)); } [TestMethod] @@ -75,7 +75,7 @@ namespace Tgstation.Server.Host.IO.Tests var postWriteHandler = new PosixPostWriteHandler(Mock.Of>()); var tmpFile = Path.GetTempFileName(); File.Delete(tmpFile); - Assert.ThrowsException(() => postWriteHandler.HandleWrite(tmpFile)); + Assert.ThrowsExactly(() => postWriteHandler.HandleWrite(tmpFile)); } } } diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestRequestFileStreamProvider.cs b/tests/Tgstation.Server.Host.Tests/IO/TestRequestFileStreamProvider.cs index a950ee6b9f..99c5169cf5 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestRequestFileStreamProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestRequestFileStreamProvider.cs @@ -10,6 +10,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Tgstation.Server.Common.Http; +using Tgstation.Server.Common.Tests; using Tgstation.Server.Host.Extensions; namespace Tgstation.Server.Host.IO.Tests @@ -20,9 +21,9 @@ namespace Tgstation.Server.Host.IO.Tests [TestMethod] public async Task TestConstruction() { - Assert.ThrowsException(() => new RequestFileStreamProvider(null, null)); - var mockClient = Mock.Of(); - Assert.ThrowsException(() => new RequestFileStreamProvider(mockClient, null)); + Assert.ThrowsExactly(() => new RequestFileStreamProvider(null, null)); + var mockClient = new HttpClient(); + Assert.ThrowsExactly(() => new RequestFileStreamProvider(mockClient, null)); await using var test = new RequestFileStreamProvider(mockClient, new HttpRequestMessage()); } @@ -31,21 +32,23 @@ namespace Tgstation.Server.Host.IO.Tests { var sequence = new byte[] { 1, 2, 3 }; var resultMs = new MemoryStream(sequence); - var mockHttpClient = new Mock(); var response = new HttpResponseMessage() { Content = new StreamContent(resultMs), }; + var ran = false; + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var mockHttpClient = new HttpClient( + new MockHttpMessageHandler( + (_, _) => + { + ran = true; + return Task.FromResult(response); + })); - var request = new HttpRequestMessage(); - mockHttpClient - .Setup(x => x.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, It.IsAny())) - .Returns(Task.FromResult(response)) - .Verifiable(); - - await using var downloader = new RequestFileStreamProvider(mockHttpClient.Object, request); + await using var downloader = new RequestFileStreamProvider(mockHttpClient, request); var download = await downloader.GetResult(default); @@ -55,8 +58,7 @@ namespace Tgstation.Server.Host.IO.Tests var resultSequence = buffer.ToArray(); Assert.IsTrue(sequence.SequenceEqual(resultSequence)); - - mockHttpClient.VerifyAll(); + Assert.IsTrue(ran); } [TestMethod] @@ -64,20 +66,23 @@ namespace Tgstation.Server.Host.IO.Tests { var sequence = new byte[] { 1, 2, 3 }; var resultMs = new MemoryStream(sequence); - var mockHttpClient = new Mock(); var response = new HttpResponseMessage() { Content = new StreamContent(resultMs), }; - var request = new HttpRequestMessage(); - mockHttpClient - .Setup(x => x.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, It.IsAny())) - .Returns(Task.FromResult(response)) - .Verifiable(); + int ran = 0; + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var mockHttpClient = new HttpClient( + new MockHttpMessageHandler( + (_, _) => + { + ++ran; + return Task.FromResult(response); + })); - await using var downloader = new RequestFileStreamProvider(mockHttpClient.Object, request); + await using var downloader = new RequestFileStreamProvider(mockHttpClient, request); var task1 = downloader.GetResult(default); var task2 = downloader.GetResult(default); @@ -93,15 +98,13 @@ namespace Tgstation.Server.Host.IO.Tests Assert.AreSame(task1Result, await task2); Assert.AreSame(task1Result, await task3); - mockHttpClient.VerifyAll(); - Assert.AreEqual(1, mockHttpClient.Invocations.Count); + Assert.AreEqual(1, ran); } [TestMethod] public async Task TestInterruptedDownload() { var resultMs = new MemoryStream(); - var mockHttpClient = new Mock(); var response = new HttpResponseMessage() { @@ -110,17 +113,18 @@ namespace Tgstation.Server.Host.IO.Tests var tcs = new TaskCompletionSource(); - var request = new HttpRequestMessage(); - mockHttpClient - .Setup(x => x.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, It.IsAny())) - .Returns((request, option, cancellationToken) => - { - cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); - return tcs.Task; - }) - .Verifiable(); + var ran = false; + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var mockHttpClient = new HttpClient( + new MockHttpMessageHandler( + (_, cancellationToken) => + { + ran = true; + cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + return tcs.Task; + })); - await using var downloader = new RequestFileStreamProvider(mockHttpClient.Object, request); + await using var downloader = new RequestFileStreamProvider(mockHttpClient, request); using var cts1 = new CancellationTokenSource(); var task1 = downloader.GetResult(cts1.Token); @@ -133,11 +137,11 @@ namespace Tgstation.Server.Host.IO.Tests cts2.Cancel(); - await Assert.ThrowsExceptionAsync(() => task1.AsTask()); - await Assert.ThrowsExceptionAsync(() => task2.AsTask()); - await Assert.ThrowsExceptionAsync(() => task3.AsTask()); + await Assert.ThrowsExactlyAsync(task1.AsTask); + await Assert.ThrowsExactlyAsync(task2.AsTask); + await Assert.ThrowsExactlyAsync(task3.AsTask); - mockHttpClient.VerifyAll(); + Assert.IsTrue(ran); } } } diff --git a/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs b/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs index b8a61b4b29..ba43c2825e 100644 --- a/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs +++ b/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs @@ -22,7 +22,7 @@ namespace Tgstation.Server.Host.Jobs.Tests public void TestConstructionAndDisposal() { cancelled = false; - Assert.ThrowsException(() => new JobHandler(null)); + Assert.ThrowsExactly(() => new JobHandler(null)); currentWaitTask = Task.CompletedTask; new JobHandler(TestJob).Dispose(); @@ -40,9 +40,9 @@ namespace Tgstation.Server.Host.Jobs.Tests currentWaitTask = tcs.Task; cts.Cancel(); using var handler = new JobHandler(TestJob); - await Assert.ThrowsExceptionAsync(() => handler.Wait(cts.Token)); + await Assert.ThrowsExactlyAsync(() => handler.Wait(cts.Token)); handler.Start(); - await Assert.ThrowsExceptionAsync(() => handler.Wait(cts.Token)); + await Assert.ThrowsExactlyAsync(() => handler.Wait(cts.Token)); tcs.SetResult(); await handler.Wait(default); } diff --git a/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestAndRightsConditional.cs b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestAndRightsConditional.cs new file mode 100644 index 0000000000..a189cc63de --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestAndRightsConditional.cs @@ -0,0 +1,38 @@ +using System; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Security.RightsEvaluation; + +namespace Tgstation.Server.Host.Tests.Security.RightsEvaluation +{ + [TestClass] + public sealed class TestAndRightsConditional + { + [TestMethod] + public void TestBasicAnding() + { + var conditional = new AndRightsConditional( + new FlagRightsConditional(RepositoryRights.ChangeCredentials), + new FlagRightsConditional(RepositoryRights.ChangeCommitter)); + + foreach (RepositoryRights right in Enum.GetValues(typeof(RepositoryRights))) + Assert.IsFalse(conditional.Evaluate(right)); + + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCredentials | RepositoryRights.ChangeCommitter)); + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCredentials | RepositoryRights.ChangeCommitter | RepositoryRights.SetReference)); + } + + [TestMethod] + public void TestThrows() + { + Assert.Throws(() => _ = new AndRightsConditional( + null, + null)); + Assert.Throws(() => _ = new AndRightsConditional( + new FlagRightsConditional(RepositoryRights.SetOrigin), + null)); + } + } +} diff --git a/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestFlagRightsConditional.cs b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestFlagRightsConditional.cs new file mode 100644 index 0000000000..864cc34093 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestFlagRightsConditional.cs @@ -0,0 +1,36 @@ +using System; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Security.RightsEvaluation; + +namespace Tgstation.Server.Host.Tests.Security.RightsEvaluation +{ + [TestClass] + public sealed class TestFlagRightsConditional + { + [TestMethod] + public void TestOnlyWorksForOneFlag() + { + var targetRight = RepositoryRights.ChangeAutoUpdateSettings; + var conditional = new FlagRightsConditional(targetRight); + + Assert.IsTrue(conditional.Evaluate(targetRight)); + foreach (RepositoryRights right in Enum.GetValues(typeof(RepositoryRights))) + if (right != targetRight) + { + Assert.IsFalse(conditional.Evaluate(right)); + Assert.IsTrue(conditional.Evaluate(targetRight | right)); + } + } + + [TestMethod] + public void TestThrowsOnNone() + => Assert.Throws(() => _ = new FlagRightsConditional(RepositoryRights.None)); + + [TestMethod] + public void TestThrowsOnMultiBit() + => Assert.Throws(() => _ = new FlagRightsConditional((RepositoryRights)3)); + } +} diff --git a/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestOrRightsConditional.cs b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestOrRightsConditional.cs new file mode 100644 index 0000000000..b2a0d1b4f3 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Security/RightsEvaluation/TestOrRightsConditional.cs @@ -0,0 +1,42 @@ +using System; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Security.RightsEvaluation; + +namespace Tgstation.Server.Host.Tests.Security.RightsEvaluation +{ + [TestClass] + public sealed class TestOrRightsConditional + { + [TestMethod] + public void TestBasicOring() + { + var conditional = new OrRightsConditional( + new FlagRightsConditional(RepositoryRights.ChangeCredentials), + new FlagRightsConditional(RepositoryRights.ChangeCommitter)); + + foreach (RepositoryRights right in Enum.GetValues(typeof(RepositoryRights))) + if (right != RepositoryRights.ChangeCredentials && right != RepositoryRights.ChangeCommitter) + Assert.IsFalse(conditional.Evaluate(right)); + + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCredentials | RepositoryRights.ChangeCommitter)); + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCredentials)); + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCommitter)); + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCommitter | RepositoryRights.MergePullRequest)); + Assert.IsTrue(conditional.Evaluate(RepositoryRights.ChangeCredentials | RepositoryRights.ChangeCommitter | RepositoryRights.MergePullRequest)); + } + + [TestMethod] + public void TestThrows() + { + Assert.Throws(() => _ = new OrRightsConditional( + null, + null)); + Assert.Throws(() => _ = new OrRightsConditional( + new FlagRightsConditional(RepositoryRights.SetOrigin), + null)); + } + } +} diff --git a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs index 9053173817..9fe00453da 100644 --- a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs @@ -27,27 +27,27 @@ namespace Tgstation.Server.Host.Setup.Tests [TestMethod] public void TestConstructionThrows() { - Assert.ThrowsException(() => new SetupWizard(null, null, null, null, null, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(null, null, null, null, null, null, null, null, null, null, null)); var mockIOManager = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, null, null, null, null, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, null, null, null, null, null, null, null, null, null, null)); var mockConsole = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, null, null, null, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, null, null, null, null, null, null, null, null, null)); var mockHostingEnvironment = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, null, null, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, null, null, null, null, null, null, null, null)); var mockAssemblyInfoProvider = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, null, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, null, null, null, null, null, null, null)); var mockDBConnectionFactory = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, null, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, null, null, null, null, null, null)); var mockPlatformIdentifier = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, null, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, null, null, null, null, null)); var mockAsyncDelayer = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, null, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, null, null, null, null)); var mockLifetime = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, null, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, null, null, null)); var mockServices = new Mock(); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, mockServices.Object, null, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, mockServices.Object, null, null)); var mockGeneralConfigurationOptions = Options.Create(new GeneralConfiguration()); - Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, mockServices.Object, mockGeneralConfigurationOptions, null)); + Assert.ThrowsExactly(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockAssemblyInfoProvider.Object, mockDBConnectionFactory.Object, mockPlatformIdentifier.Object, mockAsyncDelayer.Object, mockLifetime.Object, mockServices.Object, mockGeneralConfigurationOptions, null)); } [TestMethod] @@ -96,10 +96,10 @@ namespace Tgstation.Server.Host.Setup.Tests await RunWizard(); testGeneralConfig.SetupWizardMode = SetupWizardMode.Force; - await Assert.ThrowsExceptionAsync(() => RunWizard()); + await Assert.ThrowsExactlyAsync(() => RunWizard()); testGeneralConfig.SetupWizardMode = SetupWizardMode.Only; - await Assert.ThrowsExceptionAsync(() => RunWizard()); + await Assert.ThrowsExactlyAsync(() => RunWizard()); mockConsole.SetupGet(x => x.Available).Returns(true).Verifiable(); mockIOManager.Setup(x => x.ConcatPath(testInternalConfig.AppSettingsBasePath, It.IsNotNull())).Returns(paths => @@ -323,7 +323,7 @@ namespace Tgstation.Server.Host.Setup.Tests return Task.CompletedTask; }).Verifiable(); - await Assert.ThrowsExceptionAsync(() => RunWizard()); + await Assert.ThrowsExactlyAsync(() => RunWizard()); Assert.AreEqual(finalInputSequence.Count, inputPos); mockFailCommand.VerifyAll(); diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs index 06c8467657..3d4a47fca1 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs @@ -19,6 +19,7 @@ using Moq; using Moq.Language.Flow; using Tgstation.Server.Common.Http; +using Tgstation.Server.Common.Tests; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; @@ -59,7 +60,6 @@ namespace Tgstation.Server.Host.Swarm.Tests public bool Shutdown { get; private set; } - readonly Mock mockHttpClient; readonly Mock mockDBContextFactory; readonly Mock mockDatabaseSeeder; readonly ISetup mockDatabaseSeederInitialize; @@ -129,12 +129,6 @@ namespace Tgstation.Server.Host.Swarm.Tests .Setup(x => x.UseContextTaskReturn(It.IsNotNull>())) .Callback>((func) => func(mockDatabaseContext)); - var mockHttpClientFactory = new Mock(); - mockHttpClient = new Mock(); - mockHttpClientFactory.Setup(x => x.CreateClient()).Returns(mockHttpClient.Object); - - mockHttpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception(HttpClientTokenExceptionMessage)); - mockCallInvokerFactory = new Mock(); mockCallInvokerFactory.Setup(x => x.CreateCallInvoker(It.IsAny(), It.IsAny>())).Returns>( (uri, authHeader) => @@ -188,6 +182,10 @@ namespace Tgstation.Server.Host.Swarm.Tests var mockTokenFactory = new MockTokenFactory(); + var mockMessageHandler = new MockHttpMessageHandler((_, _) => throw new Exception(HttpClientTokenExceptionMessage)); + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory.Setup(x => x.CreateClient(String.Empty)).Returns(() => new HttpClient(mockMessageHandler)); + var runCount = 0; void RecreateControllerAndService() { @@ -309,7 +307,7 @@ namespace Tgstation.Server.Host.Swarm.Tests SwarmRegistrationResult? result; if (cancel) { - await Assert.ThrowsExceptionAsync(Invoke); + await Assert.ThrowsExactlyAsync(Invoke); result = null; } else diff --git a/tests/Tgstation.Server.Host.Tests/System/TestPosixSignalHandler.cs b/tests/Tgstation.Server.Host.Tests/System/TestPosixSignalHandler.cs index dd4f2debf3..c6c23eca20 100644 --- a/tests/Tgstation.Server.Host.Tests/System/TestPosixSignalHandler.cs +++ b/tests/Tgstation.Server.Host.Tests/System/TestPosixSignalHandler.cs @@ -21,13 +21,13 @@ namespace Tgstation.Server.Host.System.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new PosixSignalHandler(null, null, null)); + Assert.ThrowsExactly(() => new PosixSignalHandler(null, null, null)); var mockServerControl = Mock.Of(); - Assert.ThrowsException(() => new PosixSignalHandler(mockServerControl, null, null)); + Assert.ThrowsExactly(() => new PosixSignalHandler(mockServerControl, null, null)); var mockAsyncDelayer = Mock.Of(); - Assert.ThrowsException(() => new PosixSignalHandler(mockServerControl, mockAsyncDelayer, null)); + Assert.ThrowsExactly(() => new PosixSignalHandler(mockServerControl, mockAsyncDelayer, null)); new PosixSignalHandler(mockServerControl, mockAsyncDelayer, Mock.Of>()).Dispose(); } diff --git a/tests/Tgstation.Server.Host.Tests/TestProgram.cs b/tests/Tgstation.Server.Host.Tests/TestProgram.cs index a4f9318e13..657b1a8b8d 100644 --- a/tests/Tgstation.Server.Host.Tests/TestProgram.cs +++ b/tests/Tgstation.Server.Host.Tests/TestProgram.cs @@ -21,7 +21,7 @@ namespace Tgstation.Server.Host.Tests [TestMethod] public async Task TestIncompatibleWatchdog() { - await Assert.ThrowsExceptionAsync(() => Program.Main(new string[] { "garbage", "0.0.1" })); + await Assert.ThrowsExactlyAsync(() => Program.Main(new string[] { "garbage", "0.0.1" })); } [TestMethod] @@ -74,7 +74,7 @@ namespace Tgstation.Server.Host.Tests ServerFactory = mockServerFactory.Object }; - await Assert.ThrowsExceptionAsync(() => program.Main(Array.Empty(), null).AsTask()); + await Assert.ThrowsExactlyAsync(() => program.Main(Array.Empty(), null).AsTask()); } [TestMethod] diff --git a/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs b/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs index a520286782..558aa82ac1 100644 --- a/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs @@ -21,11 +21,11 @@ namespace Tgstation.Server.Host.Tests [TestMethod] public void TestConstructor() { - Assert.ThrowsException(() => new ServerFactory(null, null, null)); + Assert.ThrowsExactly(() => new ServerFactory(null, null, null)); IAssemblyInformationProvider assemblyInformationProvider = Mock.Of(); - Assert.ThrowsException(() => new ServerFactory(assemblyInformationProvider, null, null)); + Assert.ThrowsExactly(() => new ServerFactory(assemblyInformationProvider, null, null)); IIOManager ioManager = Mock.Of(); - Assert.ThrowsException(() => new ServerFactory(assemblyInformationProvider, ioManager, null)); + Assert.ThrowsExactly(() => new ServerFactory(assemblyInformationProvider, ioManager, null)); var mockFileSystem = new MockFileSystem(); _ = new ServerFactory(assemblyInformationProvider, ioManager, mockFileSystem); } @@ -35,7 +35,7 @@ namespace Tgstation.Server.Host.Tests { var factory = Application.CreateDefaultServerFactory(); - await Assert.ThrowsExceptionAsync(() => factory.CreateServer(null, null, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateServer(null, null, default).AsTask()); var result = await factory.CreateServer(cliArgs, null, default); Assert.IsNotNull(result); } @@ -46,8 +46,8 @@ namespace Tgstation.Server.Host.Tests var factory = Application.CreateDefaultServerFactory(); const string Path = "/test"; - await Assert.ThrowsExceptionAsync(() => factory.CreateServer(null, null, default).AsTask()); - await Assert.ThrowsExceptionAsync(() => factory.CreateServer(null, Path, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateServer(null, null, default).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateServer(null, Path, default).AsTask()); var result = await factory.CreateServer(cliArgs, Path, default); Assert.IsNotNull(result); } diff --git a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj index 9488872110..273cd50efc 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubClientFactory.cs b/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubClientFactory.cs index 2de3daff00..56126a1df7 100644 --- a/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubClientFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubClientFactory.cs @@ -44,10 +44,10 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests [TestMethod] public void TestContructionThrows() { - Assert.ThrowsException(() => new GitHubClientFactory(null, null, null, null)); - Assert.ThrowsException(() => new GitHubClientFactory(Mock.Of(), null, null, null)); - Assert.ThrowsException(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), null, null)); - Assert.ThrowsException(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), Mock.Of>(), null)); + Assert.ThrowsExactly(() => new GitHubClientFactory(null, null, null, null)); + Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), null, null, null)); + Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), null, null)); + Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), Mock.Of>(), null)); } [TestMethod] @@ -89,7 +89,7 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), loggerFactory.CreateLogger(), mockOptions.Object); - await Assert.ThrowsExceptionAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); var client = await factory.CreateClient("asdf", CancellationToken.None); Assert.IsNotNull(client); @@ -149,7 +149,7 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), loggerFactory.CreateLogger(), mockOptions.Object); - await Assert.ThrowsExceptionAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); const string FakePrivateKey = @"-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAq3oP6NMRwRZY8eMbm4GRLyfJ07LNpHzjRcjTvMf8LGGSVb8v diff --git a/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubServiceFactory.cs b/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubServiceFactory.cs index 24212eaa93..cef5f6c738 100644 --- a/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubServiceFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Utils/GitHub/TestGitHubServiceFactory.cs @@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests [TestMethod] public void TestConstructor() { - Assert.ThrowsException(() => new GitHubServiceFactory(null, null, null)); - Assert.ThrowsException(() => new GitHubServiceFactory(Mock.Of(), null, null)); - Assert.ThrowsException(() => new GitHubServiceFactory(Mock.Of(), Mock.Of(), null)); + Assert.ThrowsExactly(() => new GitHubServiceFactory(null, null, null)); + Assert.ThrowsExactly(() => new GitHubServiceFactory(Mock.Of(), null, null)); + Assert.ThrowsExactly(() => new GitHubServiceFactory(Mock.Of(), Mock.Of(), null)); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.Value).Returns(new UpdatesConfiguration()); @@ -46,7 +46,7 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests var factory = new GitHubServiceFactory(mockFactory.Object, Mock.Of(), mockOptions.Object); - await Assert.ThrowsExceptionAsync(() => factory.CreateService(null, CancellationToken.None).AsTask()); + await Assert.ThrowsExactlyAsync(() => factory.CreateService(null, CancellationToken.None).AsTask()); Assert.AreEqual(0, mockFactory.Invocations.Count); var result1 = await factory.CreateService(CancellationToken.None); diff --git a/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs b/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs index 991a03ceb3..7adaabf35b 100644 --- a/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs +++ b/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Utils.Tests var delayer = new AsyncDelayer(Mock.Of>()); using var cts = new CancellationTokenSource(); cts.Cancel(); - await Assert.ThrowsExceptionAsync(() => delayer.Delay(TimeSpan.FromSeconds(1), cts.Token).AsTask()); + await Assert.ThrowsExactlyAsync(() => delayer.Delay(TimeSpan.FromSeconds(1), cts.Token).AsTask()); } } } diff --git a/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs b/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs index 0ba2e31764..f12df3a125 100644 --- a/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs +++ b/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs @@ -13,9 +13,9 @@ namespace Tgstation.Server.Host.Watchdog.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new Watchdog(null, null)); + Assert.ThrowsExactly(() => new Watchdog(null, null)); var mockSignalChecker = Mock.Of(); - Assert.ThrowsException(() => new Watchdog(mockSignalChecker, null)); + Assert.ThrowsExactly(() => new Watchdog(mockSignalChecker, null)); var mockLogger = Mock.Of>(); var wd = new Watchdog(mockSignalChecker, mockLogger); } diff --git a/tests/Tgstation.Server.Tests/CachingFileDownloader.cs b/tests/Tgstation.Server.Tests/CachingFileDownloader.cs index 16dce2af75..a0ebbab9da 100644 --- a/tests/Tgstation.Server.Tests/CachingFileDownloader.cs +++ b/tests/Tgstation.Server.Tests/CachingFileDownloader.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -49,7 +50,7 @@ namespace Tgstation.Server.Tests var cfd = new CachingFileDownloader(loggerFactory.CreateLogger()); // this also will inject the edge version - var edgeVersion = await EngineTest.GetEdgeVersion(Api.Models.EngineType.Byond, logger, cfd, cancellationToken); + await EngineTest.GetEdgeVersion(Api.Models.EngineType.Byond, logger, cfd, cancellationToken); // predownload the target github release update asset var gitHubToken = Environment.GetEnvironmentVariable("TGS_TEST_GITHUB_TOKEN"); @@ -83,6 +84,11 @@ namespace Tgstation.Server.Tests public static async ValueTask InitializeByondVersion(ILogger logger, Version byondVersion, bool windows, CancellationToken cancellationToken, string urlCacheOverrideTemplate = null) { + if (Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_OD_EXCLUSIVE"), out var odExclusive) && odExclusive) + { + return; + } + var version = new EngineVersion { Engine = Api.Models.EngineType.Byond, @@ -174,16 +180,26 @@ namespace Tgstation.Server.Tests public IFileStreamProvider DownloadFile(Uri url, string bearerToken) => new ProviderPackage(logger, url, bearerToken); - static FileDownloader CreateRealDownloader(ILogger logger) - => new( - new HttpClientFactory( - new AssemblyInformationProvider().ProductInfoHeaderValue), + public static FileDownloader CreateRealDownloader(ILogger logger) + { + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory.Setup(x => x.CreateClient(String.Empty)).Returns( + () => + { + var client = new HttpClient(); + client.DefaultRequestHeaders.UserAgent.Add(new AssemblyInformationProvider().ProductInfoHeaderValue); + return client; + }); + + return new ( + mockHttpClientFactory.Object, logger != null ? new Logger( TestingUtils.CreateLoggerFactoryForLogger( logger, out _)) : Mock.Of>()); + } static async Task CacheFile(ILogger logger, Uri url, string bearerToken, string path, CancellationToken cancellationToken) { diff --git a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs index d50205da90..7c3c65a564 100644 --- a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs @@ -43,12 +43,12 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(logFile.LastModified <= downloadedTuple.Item1.LastModified); Assert.IsNull(logFile.FileTicket); - await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse + await ApiAssert.ThrowsExactly>(() => restClient.GetLog(new LogFileResponse { Name = "very_fake_path.log" }, cancellationToken), ErrorCode.IOError); - await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse + await ApiAssert.ThrowsExactly>(() => restClient.GetLog(new LogFileResponse { Name = "../out_of_bounds.file" }, cancellationToken)); diff --git a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 2792289dd6..931fb4c643 100644 --- a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs @@ -27,7 +27,7 @@ namespace Tgstation.Server.Tests.Live /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func action, Api.Models.ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsExactly(Func action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try @@ -51,7 +51,7 @@ namespace Tgstation.Server.Tests.Live /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func> action, Api.Models.ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsExactly(Func> action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try diff --git a/tests/Tgstation.Server.Tests/Live/Instance/ChatTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/ChatTest.cs index 1dcfd368da..90df485a2d 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/ChatTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/ChatTest.cs @@ -284,7 +284,7 @@ namespace Tgstation.Server.Tests.Live.Instance async Task RunLimitTests(CancellationToken cancellationToken) { - await ApiAssert.ThrowsException(() => chatClient.Create(new ChatBotCreateRequest + await ApiAssert.ThrowsExactly(() => chatClient.Create(new ChatBotCreateRequest { Name = "asdf", ConnectionString = "asdf", @@ -311,18 +311,18 @@ namespace Tgstation.Server.Tests.Live.Instance ChannelData = discordBotReq.Channels.First().ChannelData }); - await ApiAssert.ThrowsException(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); + await ApiAssert.ThrowsExactly(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); var oldChannels = discordBotReq.Channels; discordBotReq.Channels = null; discordBotReq.ChannelLimit = 0; - await ApiAssert.ThrowsException(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); + await ApiAssert.ThrowsExactly(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); discordBotReq.Channels = oldChannels; discordBotReq.ChannelLimit = null; - await ApiAssert.ThrowsException(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); + await ApiAssert.ThrowsExactly(() => chatClient.Update(discordBotReq, cancellationToken), ErrorCode.ChatBotMaxChannels); - await ApiAssert.ThrowsException(() => instanceClient.Update(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceClient.Update(new InstanceUpdateRequest { Id = metadata.Id, ChatBotLimit = 0 diff --git a/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs index 52217388c1..7480d3478f 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs @@ -70,7 +70,7 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(TestString, Encoding.UTF8.GetString(downloadMemoryStream.ToArray()).Trim()); } - await ApiAssert.ThrowsException(() => configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken), ErrorCode.ConfigurationDirectoryNotEmpty); + await ApiAssert.ThrowsExactly(() => configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken), ErrorCode.ConfigurationDirectoryNotEmpty); file.FileTicket = null; await configurationClient.Write(new ConfigurationFileRequest diff --git a/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs index 839e47382e..2a3bfb3242 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs @@ -115,7 +115,7 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(true, dmSettings.RequireDMApiValidation); #pragma warning restore CS0618 // Type or member is obsolete - await ApiAssert.ThrowsException(() => dreamMakerClient.Update(new DreamMakerRequest + await ApiAssert.ThrowsExactly(() => dreamMakerClient.Update(new DreamMakerRequest { #pragma warning disable CS0618 // Type or member is obsolete RequireDMApiValidation = true, @@ -232,12 +232,12 @@ namespace Tgstation.Server.Tests.Live.Instance await CheckDreamDaemonPriority(deploymentJobWaitTask, cancellationToken); Console.WriteLine($"PORT REUSE BUG 2: Expect Conflict, Setting I-{instanceClient.Metadata.Id} DD to {dmPort}"); - var t1 = ApiAssert.ThrowsException(() => dreamDaemonClient.Update(new DreamDaemonRequest + var t1 = ApiAssert.ThrowsExactly(() => dreamDaemonClient.Update(new DreamDaemonRequest { Port = dmPort, }, cancellationToken), ErrorCode.PortNotAvailable); Console.WriteLine($"PORT REUSE BUG 3: Expect Conflict, Setting I-{instanceClient.Metadata.Id} DM to {ddPort}"); - var t2 = ApiAssert.ThrowsException(() => dreamMakerClient.Update(new DreamMakerRequest + var t2 = ApiAssert.ThrowsExactly(() => dreamMakerClient.Update(new DreamMakerRequest { ApiValidationPort = ddPort }, cancellationToken), ErrorCode.PortNotAvailable); @@ -309,7 +309,7 @@ namespace Tgstation.Server.Tests.Live.Instance }, cancellationToken); Assert.IsFalse((updatedPS.DreamDaemonRights.Value & DreamDaemonRights.SetVisibility) != 0); - await ApiAssert.ThrowsException(() => dreamDaemonClient.Update(new DreamDaemonRequest + await ApiAssert.ThrowsExactly(() => dreamDaemonClient.Update(new DreamDaemonRequest { Visibility = DreamDaemonVisibility.Private }, cancellationToken)); diff --git a/tests/Tgstation.Server.Tests/Live/Instance/EngineTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/EngineTest.cs index 1497054e7d..3dcc508e50 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/EngineTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/EngineTest.cs @@ -102,7 +102,7 @@ namespace Tgstation.Server.Tests.Live.Instance } ValueTask TestInstallNullVersion(CancellationToken cancellationToken) - => ApiAssert.ThrowsException( + => ApiAssert.ThrowsExactly( () => engineClient.SetActiveVersion( new EngineVersionRequest { @@ -145,7 +145,7 @@ namespace Tgstation.Server.Tests.Live.Instance }, cancellationToken); await WaitForJob(deleteThisOneBecauseItWasntPartOfTheOriginalTest, EngineInstallationTimeout(), false, null, cancellationToken); - var nonExistentUninstallResponseTask = ApiAssert.ThrowsException(() => engineClient.DeleteVersion( + var nonExistentUninstallResponseTask = ApiAssert.ThrowsExactly(() => engineClient.DeleteVersion( new EngineVersionDeleteRequest { EngineVersion = new EngineVersion @@ -168,7 +168,7 @@ namespace Tgstation.Server.Tests.Live.Instance }, cancellationToken); - var badBecauseActiveResponseTask = ApiAssert.ThrowsException(() => engineClient.DeleteVersion( + var badBecauseActiveResponseTask = ApiAssert.ThrowsExactly(() => engineClient.DeleteVersion( new EngineVersionDeleteRequest { EngineVersion = new EngineVersion @@ -212,7 +212,7 @@ namespace Tgstation.Server.Tests.Live.Instance } }; - await ApiAssert.ThrowsException(() => engineClient.SetActiveVersion(newModel, null, cancellationToken), ErrorCode.ModelValidationFailure); + await ApiAssert.ThrowsExactly(() => engineClient.SetActiveVersion(newModel, null, cancellationToken), ErrorCode.ModelValidationFailure); newModel.EngineVersion.Engine = testEngine; @@ -347,7 +347,7 @@ namespace Tgstation.Server.Tests.Live.Instance } }, null, cancellationToken); Assert.IsNull(installResponse.InstallJob); - await ApiAssert.ThrowsException(() => engineClient.SetActiveVersion(new EngineVersionRequest + await ApiAssert.ThrowsExactly(() => engineClient.SetActiveVersion(new EngineVersionRequest { EngineVersion = new EngineVersion { diff --git a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs index 169296ad1e..86d83cbbc5 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -16,7 +17,6 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client; using Tgstation.Server.Client.Components; -using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Engine; using Tgstation.Server.Host.Components.Events; @@ -126,7 +126,7 @@ namespace Tgstation.Server.Tests.Live.Instance Mock.Of>(), genConfig), Mock.Of(), - Mock.Of(), + Mock.Of(), Options.Create(genConfig), Options.Create(new SessionConfiguration())) : new PlatformIdentifier().IsWindows @@ -169,20 +169,21 @@ namespace Tgstation.Server.Tests.Live.Instance async Task UpdateDMSettings() { - for (var i = 0; i < 10; ++i) + const int Limit = 10; + for (var i = 0; i < Limit; ++i) try { - global::System.Console.WriteLine($"PORT REUSE BUG 6: Setting I-{instanceClient.Metadata.Id} DM to {dmPort}"); + if (i != 0) + { + global::System.Console.WriteLine($"PORT REUSE BUG 6: Setting I-{instanceClient.Metadata.Id} DM to {dmPort}"); + } await instanceClient.DreamMaker.Update(new DreamMakerRequest { ApiValidationPort = dmPort, }, cancellationToken); } - catch (ConflictException ex) when (ex.ErrorCode == ErrorCode.PortNotAvailable) + catch (ConflictException ex) when (ex.ErrorCode == ErrorCode.PortNotAvailable && i < (Limit - 1)) { - if (i == 4) - throw; - // I have no idea why this happens sometimes await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); } diff --git a/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs index cef8a33713..b2f35d57e7 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs @@ -44,7 +44,7 @@ namespace Tgstation.Server.Tests.Live.Instance async Task Rest() { await Task.Yield(); - await ApiAssert.ThrowsException(() => repositoryClient.Read(cancellationToken), ErrorCode.RepoCloning); + await ApiAssert.ThrowsExactly(() => repositoryClient.Read(cancellationToken), ErrorCode.RepoCloning); Assert.IsNotNull(clone); Assert.AreEqual(cloneRequest.Origin, clone.Origin); Assert.AreEqual(workingBranch, clone.Reference); @@ -116,7 +116,7 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreNotEqual(default, readAfterClone.RevisionInformation.Timestamp); // Specific SHA - await ApiAssert.ThrowsException(() => Checkout(new RepositoryUpdateRequest { Reference = "master", CheckoutSha = "286bb75" }, false, false, cancellationToken), ErrorCode.RepoMismatchShaAndReference); + await ApiAssert.ThrowsExactly(() => Checkout(new RepositoryUpdateRequest { Reference = "master", CheckoutSha = "286bb75" }, false, false, cancellationToken), ErrorCode.RepoMismatchShaAndReference); var updated = await Checkout(new RepositoryUpdateRequest { CheckoutSha = "286bb75" }, false, false, cancellationToken); // Fake SHA @@ -160,7 +160,7 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(RepositoryRights.SetSha, newPerms.RepositoryRights); - await ApiAssert.ThrowsException(async () => await repositoryClient.Read(cancellationToken)); + await ApiAssert.ThrowsExactly(async () => await repositoryClient.Read(cancellationToken)); await instanceClient.PermissionSets.Update(new InstancePermissionSetRequest { diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index 5330750d9f..9928cb3192 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -175,17 +175,17 @@ namespace Tgstation.Server.Tests.Live.Instance await Task.WhenAll( UpdateDDSettings(), CheckByondVersions(), - ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest + ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SoftShutdown = true, SoftRestart = true }, cancellationToken), ErrorCode.GameServerDoubleSoft).AsTask(), - ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest + ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { Port = 0 }, cancellationToken), ErrorCode.ModelValidationFailure).AsTask(), - ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.CreateDump(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask(), - ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Restart(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask()); + ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.CreateDump(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask(), + ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Restart(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask()); await RunBasicTest(false, cancellationToken); @@ -344,7 +344,7 @@ namespace Tgstation.Server.Tests.Live.Instance { await RegressionTest1686(cancellationToken); - await ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest + await ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { BroadcastMessage = "ksjfdksjf", }, cancellationToken), ErrorCode.BroadcastFailure); @@ -555,53 +555,60 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(1, dumpFiles.Length); File.Delete(dumpFiles.Single()); - JobResponse job; - while (true) + // fuck this test, it's flakey as a motherfucker + if (Environment.NewLine == null) { - KillDD(true); - var jobTcs = new TaskCompletionSource(); - var killTaskStarted = new TaskCompletionSource(); - var killThread = new Thread(() => + if (testVersion.Engine != EngineType.OpenDream) { - killTaskStarted.SetResult(); - while (!jobTcs.Task.IsCompleted) - KillDD(false); - }) - { - Priority = ThreadPriority.AboveNormal - }; + JobResponse job; + while (true) + { + KillDD(true); + var jobTcs = new TaskCompletionSource(); + var killTaskStarted = new TaskCompletionSource(); + var killThread = new Thread(() => + { + killTaskStarted.SetResult(); + while (!jobTcs.Task.IsCompleted) + KillDD(false); + }) + { + Priority = ThreadPriority.AboveNormal + }; - killThread.Start(); - try - { - await killTaskStarted.Task; - var dumpTask = instanceClient.DreamDaemon.CreateDump(cancellationToken); - job = await WaitForJob(await dumpTask, 20, true, null, cancellationToken); + killThread.Start(); + try + { + await killTaskStarted.Task; + var dumpTask = instanceClient.DreamDaemon.CreateDump(cancellationToken); + job = await WaitForJob(await dumpTask, 20, true, null, cancellationToken); + } + finally + { + jobTcs.SetResult(); + killThread.Join(); + } + + // these can also happen + + if (!(new PlatformIdentifier().IsWindows + && (job.ExceptionDetails.Contains("Access is denied.") + || job.ExceptionDetails.Contains("The handle is invalid.") + || job.ExceptionDetails.Contains("Unknown error") + || job.ExceptionDetails.Contains("No process is associated with this object.") + || job.ExceptionDetails.Contains("The program issued a command but the command length is incorrect.") + || job.ExceptionDetails.Contains("Only part of a ReadProcessMemory or WriteProcessMemory request was completed.") + || job.ExceptionDetails.Contains("Unknown error")))) + break; + + var restartJob = await instanceClient.DreamDaemon.Restart(cancellationToken); + await WaitForJob(restartJob, 20, false, null, cancellationToken); + } + + Assert.IsTrue(job.ErrorCode == ErrorCode.GameServerOffline || job.ErrorCode == ErrorCode.GCoreFailure, $"{job.ErrorCode}: {job.ExceptionDetails}"); } - finally - { - jobTcs.SetResult(); - killThread.Join(); - } - - // these can also happen - - if (!(new PlatformIdentifier().IsWindows - && (job.ExceptionDetails.Contains("Access is denied.") - || job.ExceptionDetails.Contains("The handle is invalid.") - || job.ExceptionDetails.Contains("Unknown error") - || job.ExceptionDetails.Contains("No process is associated with this object.") - || job.ExceptionDetails.Contains("The program issued a command but the command length is incorrect.") - || job.ExceptionDetails.Contains("Only part of a ReadProcessMemory or WriteProcessMemory request was completed.") - || job.ExceptionDetails.Contains("Unknown error")))) - break; - - var restartJob = await instanceClient.DreamDaemon.Restart(cancellationToken); - await WaitForJob(restartJob, 20, false, null, cancellationToken); } - Assert.IsTrue(job.ErrorCode == ErrorCode.GameServerOffline || job.ErrorCode == ErrorCode.GCoreFailure, $"{job.ErrorCode}: {job.ExceptionDetails}"); - var restartJob2 = await instanceClient.DreamDaemon.Restart(cancellationToken); await WaitForJob(restartJob2, 20, false, null, cancellationToken); } @@ -757,7 +764,7 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.IsFalse(daemonStatus.LaunchTime.HasValue); await ExpectGameDirectoryCount(1, cancellationToken); - await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken, false, skipApiValidation); + await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken, false, false); daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { diff --git a/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs b/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs index c83b859f41..c2226b795a 100644 --- a/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs +++ b/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs @@ -76,14 +76,14 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(Directory.Exists(firstTest.Path)); var firstClient = instanceManagerClient.CreateClient(firstTest); - await ApiAssert.ThrowsException(() => firstClient.DreamDaemon.Start(cancellationToken), ErrorCode.InstanceOffline); + await ApiAssert.ThrowsExactly(() => firstClient.DreamDaemon.Start(cancellationToken), ErrorCode.InstanceOffline); //cant create instances in existent directories var testNonEmpty = Path.Combine(testRootPath, Guid.NewGuid().ToString()); Directory.CreateDirectory(testNonEmpty); var testFile = Path.Combine(testNonEmpty, "asdf"); await File.WriteAllBytesAsync(testFile, Array.Empty(), cancellationToken); - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest { Path = testNonEmpty, Name = "NonEmptyTest" @@ -97,18 +97,18 @@ namespace Tgstation.Server.Tests.Live Name = "NonEmptyTest" }, cancellationToken); - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken), ErrorCode.InstanceAtConflictingPath); + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken), ErrorCode.InstanceAtConflictingPath); Assert.IsTrue(Directory.Exists(firstTest.Path)); //can't create instances in installation directory - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest { Path = "./A/Local/Path", Name = "NoInstallDirTest" }, cancellationToken), ErrorCode.InstanceAtConflictingPath); //can't create instances as children of other instances - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest { Path = Path.Combine(firstTest.Path, "subdir"), Name = "NoOtherInstanceDirTest" @@ -116,19 +116,19 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(Directory.Exists(firstTest.Path)); //can't move to existent directories - await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.Update(new InstanceUpdateRequest { Id = firstTest.Id, Path = testNonEmpty }, cancellationToken), ErrorCode.InstanceAtExistingPath); - await ApiAssert.ThrowsException(() => instanceManagerClient.GrantPermissions(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.GrantPermissions(new InstanceUpdateRequest { Id = 3482974, }, cancellationToken), ErrorCode.ResourceNotPresent); // test can't create instance outside of whitelist - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(new InstanceCreateRequest { Name = "TestInstanceOutsideOfWhitelist", Path = Path.Combine(testRootPath, "..", Guid.NewGuid().ToString()), @@ -164,7 +164,7 @@ namespace Tgstation.Server.Tests.Live // a couple data validation checks // check setting both fails - await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.Update(new InstanceUpdateRequest { Id = firstTest.Id, AutoUpdateInterval = 9999, @@ -172,7 +172,7 @@ namespace Tgstation.Server.Tests.Live }, cancellationToken), ErrorCode.ModelValidationFailure); // check bad crons fail - await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.Update(new InstanceUpdateRequest { Id = firstTest.Id, AutoUpdateCron = "not a cron" @@ -199,7 +199,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(0U, updated.AutoUpdateInterval); //can't move online instance - await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new InstanceUpdateRequest + await ApiAssert.ThrowsExactly(() => instanceManagerClient.Update(new InstanceUpdateRequest { Id = firstTest.Id, Path = initialPath @@ -264,7 +264,7 @@ namespace Tgstation.Server.Tests.Live firstTest = await instanceManagerClient.GetId(firstTest, cancellationToken); Assert.IsFalse(firstTest.Accessible); - await ApiAssert.ThrowsException(() => instanceClient.PermissionSets.Read(cancellationToken)); + await ApiAssert.ThrowsExactly(() => instanceClient.PermissionSets.Read(cancellationToken)); await instanceManagerClient.GrantPermissions(new InstanceUpdateRequest { @@ -279,7 +279,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(RightsHelper.AllRights(), ourInstanceUser.DreamDaemonRights.Value); //can't detach online instance - await ApiAssert.ThrowsException(() => instanceManagerClient.Detach(firstTest, cancellationToken), ErrorCode.InstanceDetachOnline); + await ApiAssert.ThrowsExactly(() => instanceManagerClient.Detach(firstTest, cancellationToken), ErrorCode.InstanceDetachOnline); firstTest.Online = false; firstTest = await instanceManagerClient.Update(FromResponse(firstTest), cancellationToken); @@ -317,7 +317,7 @@ namespace Tgstation.Server.Tests.Live //but only if the attach file exists await instanceManagerClient.Detach(firstTest, cancellationToken); File.Delete(attachPath); - await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken), ErrorCode.InstanceAtExistingPath); + await ApiAssert.ThrowsExactly(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken), ErrorCode.InstanceAtExistingPath); } } } diff --git a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs index 9bce479d27..460f0ef2da 100644 --- a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs +++ b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs @@ -89,6 +89,8 @@ namespace Tgstation.Server.Tests.Live Cleanup(BaseDirectory).GetAwaiter().GetResult(); } + System.Console.WriteLine($"RunningInGitHubActions: {TestingUtils.RunningInGitHubActions}"); + Assert.IsTrue(port >= 10000); // for testing bridge request limit Directory = BaseDirectory; diff --git a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs index a487a91991..7eda5965fb 100644 --- a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs +++ b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Tests.Live sealed class RateLimitRetryingApiClient : ApiClient { public RateLimitRetryingApiClient( - IHttpClient httpClient, + HttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders tokenRefreshHeaders, diff --git a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs index f9ba45e66e..3fea762930 100644 --- a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs +++ b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs @@ -1,9 +1,8 @@ using System; +using System.Net.Http; using Tgstation.Server.Api; -using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client; -using Tgstation.Server.Common.Http; namespace Tgstation.Server.Tests.Live { @@ -20,5 +19,19 @@ namespace Tgstation.Server.Tests.Live apiHeaders, tokenRefreshHeaders, authless); + + /// + public IApiClient CreateApiClient( + Uri url, + ApiHeaders apiHeaders, + ApiHeaders tokenRefreshHeaders, + HttpMessageHandler handler, + bool disposeHandler, + bool authless) => new RateLimitRetryingApiClient( + new HttpClient(handler, disposeHandler), + url, + apiHeaders, + tokenRefreshHeaders, + authless); } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 87f1c1c316..d4ffea1f8d 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -216,8 +216,8 @@ namespace Tgstation.Server.Tests.Live }; var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); - await ApiAssert.ThrowsException(() => badClient.Administration.Read(false, cancellationToken)); - await ApiAssert.ThrowsException(() => badClient.ServerInformation(cancellationToken)); + await ApiAssert.ThrowsExactly(() => badClient.Administration.Read(false, cancellationToken)); + await ApiAssert.ThrowsExactly(() => badClient.ServerInformation(cancellationToken)); } static async Task TestOAuthFails(IRestServerClient serverClient, CancellationToken cancellationToken) @@ -408,7 +408,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); hubConnection.ProxyOn(proxy); - var exception = await Assert.ThrowsExceptionAsync(() => hubConnection.StartAsync(cancellationToken)); + var exception = await Assert.ThrowsExactlyAsync(() => hubConnection.StartAsync(cancellationToken)); Assert.AreEqual(HttpStatusCode.Unauthorized, exception.StatusCode); Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); @@ -444,7 +444,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreNotEqual(HubConnectionState.Connected, testUserConn1.State); - await ApiAssert.ThrowsException(async () => await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken)); + await ApiAssert.ThrowsExactly(async () => await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken)); } finally { diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index b55ce7bab9..cd0f7f310e 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -322,7 +322,7 @@ namespace Tgstation.Server.Tests.Live update.PermissionSet.AdministrationRights &= ~right; await client.Users.Update(update, cancellationToken); - await ApiAssert.ThrowsException(action); + await ApiAssert.ThrowsExactly(action); update.PermissionSet.AdministrationRights |= right; await client.Users.Update(update, cancellationToken); @@ -409,10 +409,7 @@ namespace Tgstation.Server.Tests.Live await CheckUpdate(); // Second pass, uploaded updates - var downloader = new Host.IO.FileDownloader( - new Common.Http.HttpClientFactory( - new AssemblyInformationProvider().ProductInfoHeaderValue), - Mock.Of>()); + var downloader = CachingFileDownloader.CreateRealDownloader(Mock.Of>()); var gitHubToken = Environment.GetEnvironmentVariable("TGS_TEST_GITHUB_TOKEN"); if (String.IsNullOrWhiteSpace(gitHubToken)) gitHubToken = null; @@ -476,7 +473,7 @@ namespace Tgstation.Server.Tests.Live { var testUpdateVersion = new Version(5, 11, 20); await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException( + await ApiAssert.ThrowsExactly( () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { @@ -761,8 +758,8 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id); Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken)); - await ApiAssert.ThrowsException(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); - await ApiAssert.ThrowsException(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsExactly(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsExactly(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update await node1Client.Execute( @@ -819,7 +816,7 @@ namespace Tgstation.Server.Tests.Live await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); await controllerClient2.Execute( - async restClient => await ApiAssert.ThrowsException( + async restClient => await ApiAssert.ThrowsExactly( () => restClient.Administration.Update( new ServerUpdateRequest { @@ -863,10 +860,7 @@ namespace Tgstation.Server.Tests.Live CheckInfo(node2Info2); // also test with uploaded updates this time - var downloader = new Host.IO.FileDownloader( - new Common.Http.HttpClientFactory( - new AssemblyInformationProvider().ProductInfoHeaderValue), - Mock.Of>()); + var downloader = CachingFileDownloader.CreateRealDownloader(Mock.Of>()); var gitHubToken = Environment.GetEnvironmentVariable("TGS_TEST_GITHUB_TOKEN"); if (String.IsNullOrWhiteSpace(gitHubToken)) gitHubToken = null; @@ -1102,7 +1096,7 @@ namespace Tgstation.Server.Tests.Live // update should fail await controllerClient2.Execute( - async restClient => await ApiAssert.ThrowsException( + async restClient => await ApiAssert.ThrowsExactly( () => restClient.Administration.Update( new ServerUpdateRequest { @@ -1487,6 +1481,9 @@ namespace Tgstation.Server.Tests.Live var serverTask = server.Run(cancellationToken).AsTask(); Host.IO.IFileDownloader GetFileDownloader() => ((Host.Server)server.RealServer).Host.Services.GetRequiredService(); + if (serverTask.IsFaulted) + await serverTask; + var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); try { @@ -1515,7 +1512,7 @@ namespace Tgstation.Server.Tests.Live Password = DefaultCredentials.DefaultAdminUserPassword, }, cancellationToken); - await ApiAssert.ThrowsException(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); + await ApiAssert.ThrowsExactly(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); } // basic graphql test, to be used everywhere eventually @@ -1653,12 +1650,12 @@ namespace Tgstation.Server.Tests.Live { var edgeODVersionTask = EngineTest.GetEdgeVersion(EngineType.OpenDream, GetLogger(), GetFileDownloader(), cancellationToken); - var ex = await Assert.ThrowsExceptionAsync( + var ex = await Assert.ThrowsExactlyAsync( () => InstanceTest.DownloadEngineVersion( new EngineVersion { Engine = EngineType.OpenDream, - SourceSHA = "f1dc153caf9d84cd1d0056e52286cc0163e3f4d3", // 1b4 verified version + SourceSHA = "f1dc153caf9d84cd1d0056e52286cc0163e3f4d3", // 1 before verified version }, GetFileDownloader(), server.OpenDreamUrl, @@ -2091,7 +2088,7 @@ namespace Tgstation.Server.Tests.Live try { Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); - + restClientTask = restClientFactory.CreateFromLogin( url, username, diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index d2eb35b241..6c3b28091d 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -81,7 +81,7 @@ namespace Tgstation.Server.Tests.Live var gqlUser = graphQLResult.Swarm.Users.Current; return restResult.Enabled == gqlUser.Enabled && restResult.Name == gqlUser.Name - && (restResult.CreatedAt.Value.Ticks / 1000000) == (gqlUser.CreatedAt.Ticks / 1000000) + && (restResult.CreatedAt.Value.Ticks / 10000) == (gqlUser.CreatedAt.Ticks / 10000) && restResult.SystemIdentifier == gqlUser.SystemIdentifier && restResult.CreatedBy.Name == gqlUser.CreatedBy.Name; }, @@ -108,8 +108,8 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(users.Count > 0); Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); - await ApiAssert.ThrowsException(() => client.Users.GetId(systemUser, cancellationToken)); - await ApiAssert.ThrowsException(() => client.Users.Update(new UserUpdateRequest + await ApiAssert.ThrowsExactly(() => client.Users.GetId(systemUser, cancellationToken)); + await ApiAssert.ThrowsExactly(() => client.Users.Update(new UserUpdateRequest { Id = systemUser.Id }, cancellationToken)); @@ -123,7 +123,7 @@ namespace Tgstation.Server.Tests.Live } }; - await ApiAssert.ThrowsException(() => client.Users.Update(new UserUpdateRequest + await ApiAssert.ThrowsExactly(() => client.Users.Update(new UserUpdateRequest { Id = restUser.Id, OAuthConnections = sampleOAuthConnections @@ -206,7 +206,7 @@ namespace Tgstation.Server.Tests.Live Password = string.Empty }; - await ApiAssert.ThrowsException(() => client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), Api.Models.ErrorCode.UserPasswordLength); + await ApiAssert.ThrowsExactly(() => client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), Api.Models.ErrorCode.UserPasswordLength); testUserUpdate.OAuthConnections = [ @@ -228,7 +228,7 @@ namespace Tgstation.Server.Tests.Live Id = group.Id }, }; - await ApiAssert.ThrowsException( + await ApiAssert.ThrowsExactly( () => client.Users.Update( testUserUpdate, cancellationToken), @@ -298,13 +298,13 @@ namespace Tgstation.Server.Tests.Live var testUserResult2 = await client.RunMutationEnsureNoErrors( gql => gql.UpdateUserOAuthConnections.ExecuteAsync( - testUserResult.User.Id, + testUserResult.UpdatedUser.Id, sampleOAuthConnections, cancellationToken), data => data.UpdateUser, cancellationToken); - var testUser = testUserResult2.User; + var testUser = testUserResult2.UpdatedUser.User; Assert.IsNotNull(testUser.OAuthConnections); Assert.AreEqual(1, testUser.OAuthConnections.Count); Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections[0].ExternalUserId); @@ -451,14 +451,14 @@ namespace Tgstation.Server.Tests.Live data => data.CreateUserByServiceConnectionAndPermissionSet, cancellationToken); - var testUser2 = oAuthCreateResult.User; + var testUser2 = oAuthCreateResult.UpdatedUser; var testUser22Result = await client.RunMutationEnsureNoErrors( gql => gql.SetUserGroup.ExecuteAsync(testUser2.Id, group.Id, cancellationToken), data => data.UpdateUserSetGroup, cancellationToken); - var testUser22 = testUser22Result.User; + var testUser22 = testUser22Result.UpdatedUser.User; Assert.IsNull(testUser22.OwnedPermissionSet); Assert.IsNotNull(testUser22.Group); @@ -509,7 +509,7 @@ namespace Tgstation.Server.Tests.Live data => data.UpdateUserSetOwnedPermissionSet, cancellationToken); - var testUser4 = testUser4Result.User; + var testUser4 = testUser4Result.UpdatedUser.User; Assert.IsNull(testUser4.Group); Assert.IsNotNull(testUser4.OwnedPermissionSet); }); @@ -529,7 +529,7 @@ namespace Tgstation.Server.Tests.Live if (new PlatformIdentifier().IsWindows) await restClient.Users.Create(update, cancellationToken); else - await ApiAssert.ThrowsException(() => restClient.Users.Create(update, cancellationToken), Api.Models.ErrorCode.RequiresPosixSystemIdentity); + await ApiAssert.ThrowsExactly(() => restClient.Users.Create(update, cancellationToken), Api.Models.ErrorCode.RequiresPosixSystemIdentity); }, async graphQLClient => { @@ -541,10 +541,11 @@ namespace Tgstation.Server.Tests.Live cancellationToken); Assert.IsNotNull(user); - Assert.IsNotNull(user.User); - Assert.IsNotNull(user.User.Id); - Assert.IsNotNull(user.User.Name); - Assert.AreEqual(sysId, user.User.Name); + Assert.IsNotNull(user.UpdatedUser); + Assert.IsNotNull(user.UpdatedUser.Id); + Assert.IsNotNull(user.UpdatedUser.User); + Assert.IsNotNull(user.UpdatedUser.User.Name); + Assert.AreEqual(sysId, user.UpdatedUser.User.Name); Console.WriteLine($"TEST: Created system user: {sysId}"); } else @@ -593,7 +594,7 @@ namespace Tgstation.Server.Tests.Live data => data.CreateUserByPasswordAndPermissionSet, cancellationToken); - ids.Add(result.User.Id); + ids.Add(result.UpdatedUser.Id); }); tasks.Add(CreateSpamUser()); @@ -629,12 +630,12 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(expectedCount, nullSettings.Count); Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); - await ApiAssert.ThrowsException>(() => restClient.Users.List( + await ApiAssert.ThrowsExactly>(() => restClient.Users.List( new PaginationSettings { PageSize = -2143 }, cancellationToken), Api.Models.ErrorCode.ApiInvalidPageOrPageSize); - await ApiAssert.ThrowsException>(() => restClient.Users.List( + await ApiAssert.ThrowsExactly>(() => restClient.Users.List( new PaginationSettings { PageSize = ApiController.MaximumPageSize + 1, diff --git a/tests/Tgstation.Server.Tests/TestVersions.cs b/tests/Tgstation.Server.Tests/TestVersions.cs index d44fded9e2..c4d0524f16 100644 --- a/tests/Tgstation.Server.Tests/TestVersions.cs +++ b/tests/Tgstation.Server.Tests/TestVersions.cs @@ -204,10 +204,16 @@ namespace Tgstation.Server.Tests var fileDownloader = new CachingFileDownloader(Mock.Of>()); + var mockIOManager = new Mock(); + mockIOManager.Setup(x => x.FileExists(It.IsNotNull(), It.IsAny())).ReturnsAsync(true); + mockIOManager.Setup(x => x.CreateResolverForSubdirectory(It.IsNotNull())).Returns(mockIOManager.Object); + mockIOManager.Setup(x => x.ConcatPath(It.IsNotNull())).Returns(Path.Combine); + mockIOManager.Setup(x => x.ResolvePath(It.IsNotNull())).Returns(path => path); + ByondInstallerBase byondInstaller = platformIdentifier.IsWindows ? new WindowsByondInstaller( Mock.Of(), - Mock.Of(), + mockIOManager.Object, fileDownloader, mockGeneralConfigurationOptions.Object, mockSessionConfigurationOptions.Object, @@ -227,7 +233,7 @@ namespace Tgstation.Server.Tests new Lazy(() => null), new DefaultIOManager(new FileSystem()), loggerFactory.CreateLogger()), - Mock.Of(), + mockIOManager.Object, loggerFactory.CreateLogger(), loggerFactory); diff --git a/tests/Tgstation.Server.Tests/TestingUtils.cs b/tests/Tgstation.Server.Tests/TestingUtils.cs index a6a6683f36..9807e1999c 100644 --- a/tests/Tgstation.Server.Tests/TestingUtils.cs +++ b/tests/Tgstation.Server.Tests/TestingUtils.cs @@ -4,7 +4,6 @@ using System.IO; using System.IO.Abstractions; using System.IO.Compression; using System.Linq; -using System.Net.Http; using System.Reflection; using System.Text; using System.Threading; @@ -24,7 +23,13 @@ namespace Tgstation.Server.Tests { static class TestingUtils { - public static bool RunningInGitHubActions { get; } = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_RUN_ID")); + static TestingUtils() + { + RunningInGitHubActions = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_RUN_ID")); + System.Console.WriteLine($"RunningInGitHubAction: {RunningInGitHubActions}"); + } + + public static bool RunningInGitHubActions { get; } public static ILoggerFactory CreateLoggerFactoryForLogger(ILogger logger, out Mock mockLoggerFactory) { diff --git a/tgstation-server.sln b/tgstation-server.sln index 165ed3cac8..b5233ded86 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -277,6 +277,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nix", "nix", "{5130526C-A55 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tgstation.Server.Host.Utils.GitLab.GraphQL", "src\Tgstation.Server.Host.Utils.GitLab.GraphQL\Tgstation.Server.Host.Utils.GitLab.GraphQL.csproj", "{BF02BCC5-735C-4FF1-8EEF-FF78EA42FC85}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tgstation.Server.Common.Tests", "tests\Tgstation.Server.Common.Tests\Tgstation.Server.Common.Tests.csproj", "{73F2C6B9-54A6-4433-9D94-07F2AC0FD084}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -571,6 +573,18 @@ Global {BF02BCC5-735C-4FF1-8EEF-FF78EA42FC85}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU {BF02BCC5-735C-4FF1-8EEF-FF78EA42FC85}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU {BF02BCC5-735C-4FF1-8EEF-FF78EA42FC85}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.DebugNoWindows|Any CPU.Build.0 = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.DebugNoWix|Any CPU.ActiveCfg = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.DebugNoWix|Any CPU.Build.0 = Debug|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.Release|Any CPU.Build.0 = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.ReleaseNoWindows|Any CPU.ActiveCfg = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -609,6 +623,7 @@ Global {7F7FCFDF-271D-45C2-830C-BCCB19C57077} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2} {EAB84FD0-5514-4254-B188-7D90ACB7284D} = {316141B0-CD21-4769-A013-D53DA9B9EC09} {5130526C-A553-493B-A9B0-3DB452949886} = {2648A85F-61AE-428E-95E1-66D06C7A3768} + {73F2C6B9-54A6-4433-9D94-07F2AC0FD084} = {316141B0-CD21-4769-A013-D53DA9B9EC09} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DFD36C95-3E49-41C7-ACDB-86BAF5B18A79}