diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index e0e8354c1bd..6852e5d15ec 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -578,7 +578,6 @@ jobs: configuration: ["Debug", "Release"] env: TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt - TGS_TEST_GRAPHQL: true runs-on: windows-latest steps: - name: Setup dotnet @@ -627,6 +626,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=PostgresSql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Application Name=tgstation-server;Host=127.0.0.1;Username=$USER;Database=TGS__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Setup MariaDB uses: ankane/setup-mariadb@v1 @@ -638,6 +638,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=MariaDB" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;uid=root;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Setup MySQL uses: ankane/setup-mysql@v1 @@ -657,6 +658,7 @@ jobs: TGS_CONNSTRING_VALUE="Server=(localdb)\MSSQLLocalDB;Encrypt=false;Integrated Security=true;Initial Catalog=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }};Application Name=tgstation-server" echo "TGS_TEST_CONNECTION_STRING=$(echo $TGS_CONNSTRING_VALUE)" >> $GITHUB_ENV echo "TGS_TEST_DATABASE_TYPE=SqlServer" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Checkout (Branch) uses: actions/checkout@v4 @@ -855,6 +857,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=Sqlite" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Data Source=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }}.sqlite3;Mode=ReadWriteCreate" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Set PostgresSql Connection Info if: ${{ matrix.database-type == 'PostgresSql' }} @@ -874,6 +877,7 @@ jobs: echo "TGS_TEST_DATABASE_TYPE=MySql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;Port=3307;uid=root;pwd=mysql;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV echo "Database__ServerVersion=5.7.31" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Set General__UseBasicWatchdog if: ${{ matrix.watchdog-type == 'Basic' }} diff --git a/build/Version.props b/build/Version.props index 35126b239a8..9c784ed4d7f 100644 --- a/build/Version.props +++ b/build/Version.props @@ -5,10 +5,10 @@ 6.10.0 5.2.0 - 10.9.0 + 10.10.0 7.0.0 - 15.0.0 - 18.0.0 + 16.0.0 + 19.0.0 7.3.0 5.10.0 1.5.0 diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index 72be43fd431..15ba71dc89e 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1,4 +1,4 @@ - + @@ -6,7 +6,6 @@ - diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index fad13a03305..acc673bf072 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -76,7 +76,7 @@ public sealed class ApiHeaders /// /// A containing the ':' . /// - static readonly char[] ColonSeparator = new char[] { ':' }; + static readonly char[] ColonSeparator = [':']; /// /// The instance being accessed. diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 052941d9222..37a61e55fbb 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -663,5 +663,11 @@ public enum ErrorCode : uint /// [Description("Provided repository username doesn't match the user of the corresponding access token!")] RepoTokenUsernameMismatch, + + /// + /// Attempted to make a cross swarm server request using the GraphQL API. + /// + [Description("GraphQL swarm remote gateways not implemented!")] + RemoteGatewaysNotImplemented, } } diff --git a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs deleted file mode 100644 index ae8d5ca34e8..00000000000 --- a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; - -namespace Tgstation.Server.Api.Models.Internal -{ - /// - /// Information about the local tgstation-server. - /// - public class LocalServerInformation : ServerInformationBase - { - /// - /// If the server is running on a windows operating system. - /// - public bool WindowsHost { get; set; } - - /// - /// Map of to the for them. - /// - public Dictionary? OAuthProviderInfos { get; set; } - } -} diff --git a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs index 30d379a3230..15fafe90b1d 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs @@ -31,6 +31,6 @@ public abstract class ServerInformationBase /// Limits the locations instances may be created or attached from. /// [ResponseOptions] - public ICollection? ValidInstancePaths { get; set; } + public List? ValidInstancePaths { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs index 9205e58a094..998c519b19c 100644 --- a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Information about a server in the swarm. /// - public abstract class SwarmServer : IEquatable + public abstract class SwarmServer { /// /// The public address of the server. @@ -47,12 +47,5 @@ protected SwarmServer(SwarmServer copy) PublicAddress = copy.PublicAddress; Identifier = copy.Identifier; } - - /// - public bool Equals(SwarmServer other) - => other != null - && other.Identifier == Identifier - && other.PublicAddress == PublicAddress - && other.Address == Address; } } diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs index fb24f74ad14..89238b30990 100644 --- a/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs @@ -1,13 +1,11 @@ -using System; - -using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Api.Models.Internal { /// /// Represents information about a running . /// - public class SwarmServerInformation : SwarmServer, IEquatable + public class SwarmServerInformation : SwarmServer { /// /// If the is the controller. @@ -30,10 +28,5 @@ public SwarmServerInformation(SwarmServerInformation copy) { Controller = copy.Controller; } - - /// - public bool Equals(SwarmServerInformation other) - => base.Equals(other) - && other.Controller == Controller; } } diff --git a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs index a33b2b8be22..4e80e80564f 100644 --- a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Response /// /// Represents basic server information. /// - public sealed class ServerInformationResponse : Internal.LocalServerInformation + public sealed class ServerInformationResponse : Internal.ServerInformationBase { /// /// The version of the host. @@ -23,6 +23,16 @@ public sealed class ServerInformationResponse : Internal.LocalServerInformation /// public Version? DMApiVersion { get; set; } + /// + /// If the server is running on a windows operating system. + /// + public bool WindowsHost { get; set; } + + /// + /// Map of to the for them. + /// + public Dictionary? OAuthProviderInfos { get; set; } + /// /// If there is a server update in progress. /// diff --git a/src/Tgstation.Server.Api/Models/UserName.cs b/src/Tgstation.Server.Api/Models/UserName.cs index 779f951916e..a3b6be81621 100644 --- a/src/Tgstation.Server.Api/Models/UserName.cs +++ b/src/Tgstation.Server.Api/Models/UserName.cs @@ -25,7 +25,7 @@ public override string? Name /// The child of to create. /// A new copied from . protected virtual TResultType CreateUserName() - where TResultType : UserName, new() => new TResultType + where TResultType : UserName, new() => new() { Id = Id, Name = Name, diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 2f60e9a0a0f..424d4608d50 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -47,19 +47,13 @@ public static RightsType TypeToRight() /// /// The . /// The . - /// A representing the claim role name. - public static string RoleNames(TRight right) + /// Am of s representing the claim role names. + public static IEnumerable RoleNames(TRight right) where TRight : Enum { - IEnumerable GetRoleNames() - { - foreach (Enum rightValue in Enum.GetValues(right.GetType())) - if (Convert.ToInt32(rightValue, CultureInfo.InvariantCulture) != 0 && right.HasFlag(rightValue)) - yield return String.Concat(typeof(TRight).Name, '.', rightValue.ToString()); - } - - var names = GetRoleNames(); - return String.Join(",", names); + foreach (Enum rightValue in Enum.GetValues(right.GetType())) + if (Convert.ToInt32(rightValue, CultureInfo.InvariantCulture) != 0 && right.HasFlag(rightValue)) + yield return String.Concat(typeof(TRight).Name, '.', rightValue.ToString()); } /// diff --git a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json index 0b651ad84f0..a6148380bca 100644 --- a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json +++ b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json @@ -13,7 +13,7 @@ "transportProfiles": [ { "default": "Http", - "subscription": "WebSocket" + "subscription": "Http" } ] } diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs new file mode 100644 index 00000000000..8babf9765e5 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs @@ -0,0 +1,97 @@ +using System; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + sealed class AuthenticatedGraphQLServerClient : GraphQLServerClient, IAuthenticatedGraphQLServerClient + { + /// + public ITransferClient TransferClient => restClient!.Transfer; + + /// + /// A that takes a bearer token as input and outputs a that uses it. + /// + readonly Func? getRestClientForToken; + + /// + /// The current . + /// + IRestServerClient? restClient; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + IRestServerClient restClient) + : base(graphQLClient, serviceProvider, logger) + { + this.restClient = restClient ?? throw new ArgumentNullException(nameof(restClient)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The to call to set the async local for requests. + /// The basic to use for reauthentication. + /// The containing the initial JWT to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult, + Func getRestClientForToken) + : base( + graphQLClient, + serviceProvider, + logger, + setAuthenticationHeader, + basicCredentialsHeader, + loginResult) + { + this.getRestClientForToken = getRestClientForToken ?? throw new ArgumentNullException(nameof(getRestClientForToken)); + restClient = getRestClientForToken(loginResult.Data!.Login.Bearer!.EncodedToken); + } + + /// + public sealed override ValueTask DisposeAsync() +#pragma warning disable CA2012 // Use ValueTasks correctly + => ValueTaskExtensions.WhenAll( + base.DisposeAsync(), + restClient!.DisposeAsync()); +#pragma warning restore CA2012 // Use ValueTasks correctly + + /// + protected sealed override async ValueTask CreateUpdatedAuthenticationHeader(string bearer) + { + var baseTask = base.CreateUpdatedAuthenticationHeader(bearer); + if (restClient != null) + await restClient.DisposeAsync().ConfigureAwait(false); + + if (getRestClientForToken != null) + restClient = getRestClientForToken(bearer); + + return await baseTask.ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs new file mode 100644 index 00000000000..d02134f6e35 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs @@ -0,0 +1,51 @@ +using System; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// thrown when automatic authentication fails. + /// + public sealed class AuthenticationException : Exception + { + /// + /// The . + /// + public ILogin_Login_Errors_ErrorMessageError? ErrorMessage { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthenticationException(ILogin_Login_Errors_ErrorMessageError errorMessage) + : base(errorMessage?.Message) + { + ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + } + + /// + /// Initializes a new instance of the class. + /// + public AuthenticationException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public AuthenticationException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public AuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs new file mode 100644 index 00000000000..949ac80f922 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs @@ -0,0 +1,42 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// that applies the . + /// + sealed class AuthorizationMessageHandler : DelegatingHandler + { + /// + /// The to be applied. + /// + public static AsyncLocal Header { get; } = new AsyncLocal(); + + /// + /// override for . + /// + readonly AuthenticationHeaderValue? headerOverride; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorizationMessageHandler(AuthenticationHeaderValue? headerOverride) + { + this.headerOverride = headerOverride; + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var currentAuthHeader = headerOverride ?? Header.Value; + if (currentAuthHeader != null) + request.Headers.Authorization = currentAuthHeader; + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql new file mode 100644 index 00000000000..5c9e176dc49 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql @@ -0,0 +1,16 @@ +mutation CreateSystemUserWithPermissionSet($systemIdentifier: String!) { + createUserBySystemIDAndPermissionSet( + input: { permissionSet: {}, systemIdentifier: $systemIdentifier } + ) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql new file mode 100644 index 00000000000..9d374b5ff0b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql @@ -0,0 +1,14 @@ +mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuthConnectionInput!]!) { + createUserByOAuthAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql new file mode 100644 index 00000000000..2d8489ecb6b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql @@ -0,0 +1,40 @@ +mutation CreateUserGroup($name: String!) { + createUserGroup(input: { name: $name }) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql new file mode 100644 index 00000000000..6a99c7ede06 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql @@ -0,0 +1,40 @@ +mutation CreateUserGroupWithInstanceListPerm($name: String!) { + createUserGroup(input: { name: $name, permissionSet: { instanceManagerRights: { canList: true } } }) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql new file mode 100644 index 00000000000..8f330b03648 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql @@ -0,0 +1,14 @@ +mutation CreateUserWithPassword($name: String!, $password: String!) { + createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql new file mode 100644 index 00000000000..2d6b335889e --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql @@ -0,0 +1,18 @@ +mutation CreateUserWithPasswordSelectOAuthConnections($name: String!, $password: String!) { + createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) { + user { + id + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql new file mode 100644 index 00000000000..0b94be4e05b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql @@ -0,0 +1,11 @@ +mutation DeleteUserGroup($id: ID!) { + deleteEmptyUserGroup(input: { id: $id }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql new file mode 100644 index 00000000000..2d99ea65337 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql @@ -0,0 +1,12 @@ +mutation Login { + login { + bearer + errors { + ... on ErrorMessageError { + message + errorCode + additionalData + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql new file mode 100644 index 00000000000..1766cb4149f --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql @@ -0,0 +1,69 @@ +mutation SetFullPermsOnUserGroup($id: ID!) { + updateUserGroup( + input: { + id: $id + newPermissionSet: { + administrationRights: { + canChangeVersion: true + canDownloadLogs: true + canEditOwnOAuthConnections: true + canEditOwnPassword: true + canReadUsers: true + canWriteUsers: true + canUploadVersion: true + canRestartHost: true + } + instanceManagerRights: { + canCreate: true + canDelete: true + canGrantPermissions: true + canSetOnline: true + canSetConfiguration: true + canSetChatBotLimit: true + canSetAutoUpdate: true + canRename: true + canRead: true + canList: true + canRelocate: true + } + } + } + ) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql new file mode 100644 index 00000000000..d68865e5509 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql @@ -0,0 +1,41 @@ +mutation SetUserGroup($id: ID!, $newGroupId: ID!) { + updateUserSetGroup(input: { id: $id, newGroupId: $newGroupId }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + ownedPermissionSet { + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + } + group { + id + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql new file mode 100644 index 00000000000..81746cf0b41 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql @@ -0,0 +1,25 @@ +mutation SetUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]!) { + updateUser( + input: { id: $id, newOAuthConnections: $newOAuthConnections } + ) { + user { + canonicalName + createdAt + enabled + id + name + systemIdentifier + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql new file mode 100644 index 00000000000..a1dbcb24e68 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql @@ -0,0 +1,66 @@ +mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) { + updateUserSetOwnedPermissionSet(input: { newPermissionSet: $permissionSet, id: $id }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + effectivePermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + ownedPermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + 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 new file mode 100644 index 00000000000..8cd18d2dc5b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql @@ -0,0 +1,18 @@ +mutation UpdateUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]) { + updateUser(input: { id: $id, newOAuthConnections: $newOAuthConnections }) { + user { + id + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql new file mode 100644 index 00000000000..f36dee754a2 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql @@ -0,0 +1,41 @@ +query GetSomeGroupInfo($id: ID!) { + swarm { + users { + groups { + byId(id: $id) { + permissionSet { + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + } + queryableUsersByGroup(first: 1) { + totalCount + nodes { + id + } + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql new file mode 100644 index 00000000000..1dc6c3a8def --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql @@ -0,0 +1,14 @@ +query GetUserById($id: ID!) { + swarm { + users { + byId(id: $id) { + canonicalName + createdAt + enabled + id + name + systemIdentifier + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql new file mode 100644 index 00000000000..5565345e8a5 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql @@ -0,0 +1,8 @@ +query GetUserNameByNodeId($id: ID!) { + node(id: $id) { + ... on UserName { + id + name + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql new file mode 100644 index 00000000000..08bc93b4fdd --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql @@ -0,0 +1,14 @@ +query ListUserGroups { + swarm { + users { + groups { + queryableGroups { + totalCount + nodes { + id + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql new file mode 100644 index 00000000000..1553df9fc09 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql @@ -0,0 +1,12 @@ +query ListUsers { + swarm { + users { + queryableUsers { + nodes { + id + } + totalCount + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql new file mode 100644 index 00000000000..e32a409b607 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql @@ -0,0 +1,18 @@ +query OAuthInformation { + swarm { + currentNode { + gateway { + information { + oAuthProviderInfos { + key + value { + clientId + redirectUri + serverUrl + } + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql new file mode 100644 index 00000000000..8db5d7db4a1 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql @@ -0,0 +1,16 @@ +query PageUserIds($first: Int, $after: String) { + swarm { + users { + queryableUsers(first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + } + totalCount + nodes { + id + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql new file mode 100644 index 00000000000..dea2849d199 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql @@ -0,0 +1,51 @@ +query ReadCurrentUser { + swarm { + users { + current { + canonicalName + createdAt + enabled + id + name + systemIdentifier + group { + id + name + } + oAuthConnections { + externalUserId + provider + } + effectivePermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + createdBy { + id + name + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql deleted file mode 100644 index 6f130e4d247..00000000000 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql +++ /dev/null @@ -1,34 +0,0 @@ -query ServerInformationQuery { - swarm { - metadata { - apiVersion - dmApiVersion - updateInProgress - version - } - localServer { - information { - instanceLimit - minimumPasswordLength - userGroupLimit - userLimit - validInstancePaths - windowsHost - oAuthProviderInfos { - key - value { - clientId - redirectUri - serverUrl - } - } - } - } - servers { - address - controller - identifier - publicAddress - } - } -} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql new file mode 100644 index 00000000000..45dbf4ebbc3 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql @@ -0,0 +1,11 @@ +query ServerVersion { + swarm { + currentNode { + gateway { + information { + version + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql new file mode 100644 index 00000000000..44bff278f0d --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql @@ -0,0 +1,19 @@ +query UnauthenticatedServerInformation { + swarm { + currentNode { + gateway { + information { + majorApiVersion + oAuthProviderInfos { + key + value { + clientId + redirectUri + serverUrl + } + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql new file mode 100644 index 00000000000..e7e5baf3de1 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql @@ -0,0 +1,3 @@ +subscription SessionInvalidation { + sessionInvalidated +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql new file mode 100644 index 00000000000..fe437ef62d1 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql @@ -0,0 +1,5 @@ +subscription SubscribeUsers { + userUpdated { + id + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index b359139b693..0f46608df1a 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -1,11 +1,34 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Api; + namespace Tgstation.Server.Client.GraphQL { /// class GraphQLServerClient : IGraphQLServerClient { + /// + /// If the was initially authenticated. + /// + [MemberNotNullWhen(true, nameof(setAuthenticationHeader))] + [MemberNotNullWhen(true, nameof(bearerCredentialsTask))] + bool Authenticated => basicCredentialsHeader != null; + + /// + /// If the supports reauthentication. + /// + [MemberNotNullWhen(true, nameof(bearerCredentialsHeaderTaskLock))] + [MemberNotNullWhen(true, nameof(basicCredentialsHeader))] + bool CanReauthenticate => basicCredentialsHeader != null; + /// /// The for the . /// @@ -16,27 +39,252 @@ class GraphQLServerClient : IGraphQLServerClient /// readonly IAsyncDisposable serviceProvider; + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The which sets the for HTTP request in the current async context. + /// + readonly Action? setAuthenticationHeader; + + /// + /// The containing the authenticated user's password credentials. + /// + readonly AuthenticationHeaderValue? basicCredentialsHeader; + + /// + /// used to synchronize access to . + /// + readonly object? bearerCredentialsHeaderTaskLock; + + /// + /// A resulting in a containing the current for the and the it expires. + /// + Task<(AuthenticationHeaderValue Header, DateTime Exp)?>? bearerCredentialsTask; + + /// + /// Throws an for a login error that previously occured outside of the current call context. + /// + /// Always thrown. + [DoesNotReturn] + static void ThrowOtherCallerFailedAuthException() + => throw new AuthenticationException("Another caller failed to authenticate!"); + /// /// Initializes a new instance of the class. /// /// The value of . /// The value of . + /// The value of . public GraphQLServerClient( IGraphQLClient graphQLClient, - IAsyncDisposable serviceProvider) + IAsyncDisposable serviceProvider, + ILogger logger) { this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient)); this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The containing the initial JWT to use. + protected GraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult) + : this(graphQLClient, serviceProvider, logger) + { + this.setAuthenticationHeader = setAuthenticationHeader ?? throw new ArgumentNullException(nameof(setAuthenticationHeader)); + ArgumentNullException.ThrowIfNull(loginResult); + this.basicCredentialsHeader = basicCredentialsHeader; + + var task = CreateCredentialsTuple(loginResult); + if (!task.IsCompleted) + throw new InvalidOperationException($"Expected {nameof(CreateCredentialsTuple)} to not await in constructor!"); + + bearerCredentialsTask = Task.FromResult<(AuthenticationHeaderValue Header, DateTime Exp)?>(task.Result); + + if (Authenticated) + bearerCredentialsHeaderTaskLock = new object(); + } + + /// + public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); + + /// + public ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(operationExecutor, cancellationToken); } /// - public ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); + public ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(async localClient => await operationExecutor(localClient), cancellationToken); + } /// - public virtual ValueTask RunQuery(Func queryExector) + public async ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + ArgumentNullException.ThrowIfNull(observer); + + var observable = operationExecutor(graphQLClient); + + if (Authenticated) + { + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + } + + // maybe make this handle reauthentication one day + // but would need to check if lost auth results in complete events being sent + // if so, it can't be done + return observable.Subscribe(observer); + } + + /// + /// Create a from a given token. + /// + /// The . + /// A new . + protected virtual ValueTask CreateUpdatedAuthenticationHeader(string bearer) + => ValueTask.FromResult( + new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + bearer)); + + /// + /// Executes a given , potentially accounting for authentication issues. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + async ValueTask> WrapAuthentication(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + if (!Authenticated) + return await operationExecutor(graphQLClient).ConfigureAwait(false); + + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + + var operationResult = await operationExecutor(graphQLClient); + + if (operationResult.IsAuthenticationError()) + { + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + setAuthenticationHeader(currentAuthHeader); + return await operationExecutor(graphQLClient); + } + + return operationResult; + } + + /// + /// Attempt to reauthenticate. + /// + /// The current for the bearer token. + /// The for the operation. + /// A resulting in the updated to use. + async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(queryExector); - return queryExector(graphQLClient); + if (!CanReauthenticate) + throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate."); + + TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null; + do + { + var bearerCredentialsTaskLocal = bearerCredentialsTask; + if (!bearerCredentialsTaskLocal!.IsCompleted) + { + var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false); + if (!currentTuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + return currentTuple.Value.Header; + } + + lock (bearerCredentialsHeaderTaskLock!) + { + if (bearerCredentialsTask == bearerCredentialsTaskLocal) + { + var result = bearerCredentialsTaskLocal.Result; + if (result?.Header != currentToken) + { + if (!result.HasValue) + ThrowOtherCallerFailedAuthException(); + + return result.Value.Header; + } + + tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>(); + bearerCredentialsTask = tcs.Task; + } + } + } + while (tcs == null); + + setAuthenticationHeader!(basicCredentialsHeader!); + var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + try + { + var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false); + tcs.SetResult(tuple); + return tuple.Header; + } + catch (AuthenticationException) + { + tcs.SetResult(null); + throw; + } + } + + /// + /// Attempt to create the for . + /// + /// The to process. + /// A resulting in a new credentials . + /// Thrown if the errored. + async ValueTask<(AuthenticationHeaderValue Header, DateTime Exp)> CreateCredentialsTuple(IOperationResult loginResult) + { + var bearer = loginResult.EnsureSuccess(logger); + + var header = await CreateUpdatedAuthenticationHeader(bearer.EncodedToken); + + return (Header: header, Exp: bearer.ValidTo); } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs index bbcd7f2f6e1..50b602685d9 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs @@ -1,11 +1,18 @@ using System; +using System.Net.Http.Headers; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using StrawberryShake; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client.GraphQL.Serializers; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Client.GraphQL { @@ -17,6 +24,45 @@ public sealed class GraphQLServerClientFactory : IGraphQLServerClientFactory /// readonly IRestServerClientFactory restClientFactory; + /// + /// Sets up a for providing the . + /// + /// The of the target tgstation-server. + /// If the should be configured. + /// The override for the . + /// The , if any. + /// A new . + static ServiceProvider SetupServiceProvider(Uri host, bool addAuthorizationHandler, AuthenticationHeaderValue? headerOverride = null, OAuthProvider? oAuthProvider = null) + { + var serviceCollection = new ServiceCollection(); + + var clientBuilder = serviceCollection + .AddGraphQLClient(); + var graphQLEndpoint = new Uri(host, Routes.GraphQL); + + clientBuilder.ConfigureHttpClient( + client => + { + client.BaseAddress = graphQLEndpoint; + client.DefaultRequestHeaders.Add(ApiHeaders.ApiVersionHeader, $"Tgstation.Server.Api/{ApiHeaders.Version.Semver()}"); + if (oAuthProvider.HasValue) + { + client.DefaultRequestHeaders.Add(ApiHeaders.OAuthProviderHeader, oAuthProvider.ToString()); + } + }, + clientBuilder => + { + if (addAuthorizationHandler) + clientBuilder.AddHttpMessageHandler(() => new AuthorizationMessageHandler(headerOverride)); + }); + + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + + return serviceCollection.BuildServiceProvider(); + } + /// /// Initializes a new instance of the class. /// @@ -29,39 +75,138 @@ public GraphQLServerClientFactory(IRestServerClientFactory restClientFactory) /// public ValueTask CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var basicCredentials = new AuthenticationHeaderValue( + ApiHeaders.BasicAuthenticationScheme, + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{username}:{password}"))); + + return CreateWithAuthCall( + host, + basicCredentials, + null, + attemptLoginRefresh, + cancellationToken); } /// public ValueTask CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var oAuthCredentials = new AuthenticationHeaderValue( + ApiHeaders.OAuthAuthenticationScheme, + oAuthCode); + + return CreateWithAuthCall( + host, + oAuthCredentials, + oAuthProvider, + false, + cancellationToken); } /// public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token) { - throw new NotImplementedException(); + var authenticationHeader = new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + token); + + var serviceProvider = SetupServiceProvider( + host, + true, + authenticationHeader); + + return new AuthenticatedGraphQLServerClient( + serviceProvider.GetRequiredService(), + serviceProvider, + serviceProvider.GetRequiredService>(), + CreateAuthenticatedTransferClient(host, token)); } /// public IGraphQLServerClient CreateUnauthenticated(Uri host) { - var serviceCollection = new ServiceCollection(); + var serviceProvider = SetupServiceProvider(host, false); - var clientBuilder = serviceCollection - .AddGraphQLClient(); - var graphQLEndpoint = new Uri(host, Routes.GraphQL); - clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint); + return new GraphQLServerClient( + serviceProvider.GetRequiredService(), + serviceProvider, + serviceProvider.GetRequiredService>()); + } - serviceCollection.AddSerializer(); - serviceCollection.AddSerializer(); + /// + /// Create an from a remote login call. + /// + /// The URL to access TGS. + /// The initial to use to login. + /// The , if any. + /// If the client should attempt to renew its sessions with the . + /// Optional for the operation. + /// A resulting in a new . + /// Thrown when authentication fails. + async ValueTask CreateWithAuthCall( + Uri host, + AuthenticationHeaderValue initialCredentials, + OAuthProvider? oAuthProvider, + bool attemptLoginRefresh, + CancellationToken cancellationToken) + { + var serviceProvider = SetupServiceProvider( + host, + true, + oAuthProvider: oAuthProvider); + try + { + var client = serviceProvider.GetRequiredService(); - var serviceProvider = serviceCollection.BuildServiceProvider(); + IOperationResult result; - return new GraphQLServerClient( - serviceProvider.GetRequiredService(), - serviceProvider); + var previousAuthHeader = AuthorizationMessageHandler.Header.Value; + AuthorizationMessageHandler.Header.Value = initialCredentials; + try + { + result = await client.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + AuthorizationMessageHandler.Header.Value = previousAuthHeader; + } + + var serverClient = new AuthenticatedGraphQLServerClient( + client, + serviceProvider, + serviceProvider.GetRequiredService>(), + newHeader => AuthorizationMessageHandler.Header.Value = newHeader, + attemptLoginRefresh ? initialCredentials : null, + result, + bearer => CreateAuthenticatedTransferClient(host, bearer)); + + await Task.Yield(); + + return serverClient; + } + catch + { + await serviceProvider.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// Create a for a given and token. + /// + /// The URL to access TGS. + /// The bearer token to access the API with. + /// A new . + IRestServerClient CreateAuthenticatedTransferClient(Uri host, string bearer) + { + var restClient = restClientFactory.CreateFromToken( + host, + new TokenResponse + { + Bearer = bearer, + }); + + return restClient; } } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs index 8c6d2ff2fe1..bddfeae618b 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using StrawberryShake; + namespace Tgstation.Server.Client.GraphQL { /// @@ -9,10 +12,36 @@ namespace Tgstation.Server.Client.GraphQL public interface IGraphQLServerClient : IAsyncDisposable { /// - /// Runs a given . It may be invoked multiple times depending on the behavior of the . + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; + + /// + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; + + /// + /// Subcribes to the GraphQL subscription indicated by . /// - /// A which executes a single query on a given and returns a representing the running operation. - /// A representing the running operation. - ValueTask RunQuery(Func queryExector); + /// The of the 's . + /// A which initiates a single subscription on a given and returns a resulting in the . + /// The for s. + /// The for the operation. + /// A resulting in the representing the lifetime of the subscription. + ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class; } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs index 06faa66adb1..5ace7c2f8ae 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs @@ -2,8 +2,6 @@ using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; - namespace Tgstation.Server.Client.GraphQL { /// @@ -24,9 +22,10 @@ public interface IGraphQLServerClientFactory /// The URL to access TGS. /// The username to for the . /// The password for the . - /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . + /// Attempt to refresh the received bearer token when it expires or becomes invalid. and will be stored in memory if this is . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromLogin( Uri host, string username, @@ -42,6 +41,7 @@ ValueTask CreateFromLogin( /// The . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromOAuth( Uri host, string oAuthCode, @@ -52,7 +52,7 @@ ValueTask CreateFromOAuth( /// Create a . /// /// The URL to access TGS. - /// The to access the API with. + /// The bearer token to access the API with. /// A new . IAuthenticatedGraphQLServerClient CreateFromToken( Uri host, diff --git a/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs new file mode 100644 index 00000000000..5ec5360815d --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; + +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using StrawberryShake; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Extensions for . + /// + static class LoginResultExtensions + { + /// + /// Check a given for errors. + /// + /// The containing the . + /// The to write to. + /// The from the successful . + /// Thrown when the is errored. + public static JsonWebToken EnsureSuccess(this IOperationResult loginResult, ILogger logger) + { + ArgumentNullException.ThrowIfNull(loginResult); + + try + { + loginResult.EnsureNoErrors(); + } + catch (GraphQLClientException ex) + { + throw new AuthenticationException("Login attempt errored at the GraphQL level!", ex); + } + + var data = loginResult.Data!.Login; + var errors = data.Errors; + if (errors != null) + { + foreach (var error in errors) + { + if (error is ILogin_Login_Errors_ErrorMessageError errorMessageError) + logger.LogError( + "Authentication error ({code}): {message}{additionalData}", + errorMessageError.ErrorCode?.ToString() ?? "No Code", + errorMessageError.Message, + errorMessageError.AdditionalData != null + ? $"{Environment.NewLine}{errorMessageError.AdditionalData}" + : String.Empty); + else + logger.LogError( + "Unknown authentication error: {error}", + error); + } + } + + var bearer = data.Bearer; + if (bearer == null) + { + if (errors != null) + { + var errorMessage = errors.OfType().FirstOrDefault(); + if (errorMessage != null) + throw new AuthenticationException(errorMessage); + + throw new AuthenticationException($"Null bearer field and {errors.Count} non-ErrorMessage errors:{(errors.Count > 0 ? $"{Environment.NewLine}\t- {String.Join($"{Environment.NewLine}\t- ", errors)}" : String.Empty)}"); + } + + throw new AuthenticationException($"Null bearer and error fields!"); + } + + return bearer; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs b/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs new file mode 100644 index 00000000000..0488db827d2 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; + +using StrawberryShake; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Extension methods for the interface. + /// + public static class OperationResultExtensions + { + /// + /// Checks if a given errored out with authentication errors. + /// + /// The . + /// if errored due to authentication issues, otherwise. + public static bool IsAuthenticationError(this IOperationResult operationResult) + { + ArgumentNullException.ThrowIfNull(operationResult); + + return operationResult.Errors.Any( + error => error.Extensions?.TryGetValue( + "code", + out object? codeExtension) == true + && codeExtension is string codeExtensionString + && codeExtensionString == "AUTH_NOT_AUTHENTICATED"); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs new file mode 100644 index 00000000000..41a0a960a20 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs @@ -0,0 +1,35 @@ +using System; + +using Microsoft.IdentityModel.JsonWebTokens; + +using StrawberryShake.Serialization; + +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql + +namespace Tgstation.Server.Client.GraphQL.Serializers +{ + /// + /// for s. + /// + sealed class JwtSerializer : ScalarSerializer + { + /// + /// Initializes a new instance of the class. + /// + public JwtSerializer() + : base("Jwt") + { + } + + /// + public override JsonWebToken Parse(string serializedValue) + => new(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue))); + + /// + protected override string Format(JsonWebToken runtimeValue) + { + ArgumentNullException.ThrowIfNull(runtimeValue); + return runtimeValue.EncodedToken; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs index cd7c6f378e3..35fea8f5091 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs @@ -4,12 +4,12 @@ using Tgstation.Server.Common.Extensions; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { /// - /// for s. + /// for s. /// sealed class SemverSerializer : ScalarSerializer { diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs index 8656438b11c..fdc610d07ce 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs @@ -2,7 +2,7 @@ using StrawberryShake.Serialization; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { diff --git a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj index 3299c2b4d95..4c1e5d1557d 100644 --- a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj +++ b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj @@ -4,10 +4,12 @@ $(TgsFrameworkVersion) $(TgsApiVersion) + enable + diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 023788fd335..8991e1fa70c 100644 --- a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -15,3 +15,4 @@ extend schema @key(fields: "id") extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") +extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken") diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index e9824a433db..0a7214e3cdc 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -194,7 +194,7 @@ public async ValueTask DisposeAsync() disposed = true; - localHubConnections = hubConnections.ToList(); + localHubConnections = [.. hubConnections]; hubConnections.Clear(); } @@ -405,51 +405,56 @@ public async ValueTask CreateHubConnection if (loggingConfigureAction != null) hubConnectionBuilder.ConfigureLogging(loggingConfigureAction); - hubConnection = hubConnectionBuilder.Build(); - try + async ValueTask AttemptConnect() { - hubConnection.Closed += async (error) => + hubConnection = hubConnectionBuilder.Build(); + try { - if (error is HttpRequestException httpRequestException) + hubConnection.Closed += async (error) => { - // .StatusCode isn't in netstandard but fuck the police - var property = error.GetType().GetProperty("StatusCode"); - if (property != null) + if (error is HttpRequestException httpRequestException) { - var statusCode = (HttpStatusCode?)property.GetValue(error); - if (statusCode == HttpStatusCode.Unauthorized - && !await RefreshToken(CancellationToken.None)) - _ = hubConnection!.StopAsync(); + // .StatusCode isn't in netstandard but fuck the police + var property = error.GetType().GetProperty("StatusCode"); + if (property != null) + { + var statusCode = (HttpStatusCode?)property.GetValue(error); + if (statusCode == HttpStatusCode.Unauthorized + && !await RefreshToken(CancellationToken.None)) + _ = hubConnection!.StopAsync(); + } } + }; + + hubConnection.ProxyOn(hubImplementation); + + Task startTask; + lock (hubConnections) + { + if (disposed) + throw new ObjectDisposedException(nameof(ApiClient)); + + hubConnections.Add(hubConnection); + startTask = hubConnection.StartAsync(cancellationToken); } - }; - hubConnection.ProxyOn(hubImplementation); + await startTask; - Task startTask; - lock (hubConnections) + return hubConnection; + } + catch { - if (disposed) - throw new ObjectDisposedException(nameof(ApiClient)); + bool needsDispose; + lock (hubConnections) + needsDispose = hubConnections.Remove(hubConnection); - hubConnections.Add(hubConnection); - startTask = hubConnection.StartAsync(cancellationToken); + if (needsDispose) + await hubConnection.DisposeAsync(); + throw; } - - await startTask; - - return hubConnection; } - catch - { - bool needsDispose; - lock (hubConnections) - needsDispose = hubConnections.Remove(hubConnection); - if (needsDispose) - await hubConnection.DisposeAsync(); - throw; - } + return await WrapHubInitialConnectAuthRefresh(AttemptConnect, cancellationToken); } /// @@ -571,6 +576,35 @@ protected virtual async ValueTask RunRequest( } #pragma warning restore CA1506 + /// + /// Wrap a hub connection attempt via a with proper token refreshing. + /// + /// The . + /// The for the operation. + /// A resulting in the connected . + async ValueTask WrapHubInitialConnectAuthRefresh(Func> connectFunc, CancellationToken cancellationToken) + { + try + { + return await connectFunc(); + } + catch (HttpRequestException ex) + { + // status code is not in netstandard + var propertyInfo = ex.GetType().GetProperty("StatusCode"); + if (propertyInfo != null) + { + var statusCode = (HttpStatusCode)propertyInfo.GetValue(ex); + if (statusCode != HttpStatusCode.Unauthorized) + throw; + } + + await RefreshToken(cancellationToken); + + return await connectFunc(); + } + } + /// /// Main request method. /// diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs new file mode 100644 index 00000000000..f0e39151a39 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -0,0 +1,131 @@ +using System; +using System.Globalization; + +using Microsoft.Extensions.Logging; + +using Octokit; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Base implementation of . + /// + abstract class AuthorityBase : IAuthority + { + /// + /// Gets the for the . + /// + protected IAuthenticationContext AuthenticationContext { get; } + + /// + /// Gets the for the . + /// + protected IDatabaseContext DatabaseContext { get; } + + /// + /// Gets the for the . + /// + protected ILogger Logger { get; } + + /// + /// Generates a type . + /// + /// The of the . + /// The . + /// A new, errored . + protected static AuthorityResponse BadRequest(ErrorCode errorCode) + => new( + new ErrorMessageResponse(errorCode), + HttpFailureResponse.BadRequest); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Unauthorized() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Unauthorized); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Gone() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Gone); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Forbid() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Forbidden); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse NotFound() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.NotFound); + + /// + /// Generates a type . + /// + /// The of the . + /// The . + /// A new, errored . + protected static AuthorityResponse Conflict(ErrorCode errorCode) + => new( + new ErrorMessageResponse(errorCode), + HttpFailureResponse.Conflict); + + /// + /// 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)); + } + + /// + /// Generates a type . + /// + /// The of the . + /// The thrown . + /// A new, errored . + protected AuthorityResponse RateLimit(RateLimitExceededException rateLimitException) + { + Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!"); + var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture); + return new( + new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit) + { + AdditionalData = $"Retry-After: {secondsString}s", + }, + HttpFailureResponse.RateLimited); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs new file mode 100644 index 00000000000..f04293aecc1 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + abstract class AuthorityInvokerBase : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// The being invoked. + /// + protected TAuthority Authority { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorityInvokerBase(TAuthority authority) + { + Authority = authority ?? throw new ArgumentNullException(nameof(authority)); + } + + /// + IQueryable IAuthorityInvoker.InvokeQueryable(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + return authorityInvoker(Authority); + } + + /// + IQueryable IAuthorityInvoker.InvokeTransformableQueryable(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var queryable = authorityInvoker(Authority); + + 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); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs new file mode 100644 index 00000000000..61b388dc830 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs @@ -0,0 +1,48 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Represents a response from an authority. + /// + public class AuthorityResponse + { + /// + /// Checks if the was successful. + /// + [MemberNotNullWhen(false, nameof(ErrorMessage))] + [MemberNotNullWhen(false, nameof(FailureResponse))] + public virtual bool Success => ErrorMessage == null; + + /// + /// Gets the associated . Must only be used if is . + /// + public ErrorMessageResponse? ErrorMessage { get; } + + /// + /// The . + /// + public HttpFailureResponse? FailureResponse { get; } + + /// + /// Initializes a new instance of the class. + /// + public AuthorityResponse() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse failureResponse) + { + ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + FailureResponse = failureResponse; + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs new file mode 100644 index 00000000000..9c2cfd295cd --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Mvc; +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// An with a . + /// + /// The of success response. + public sealed class AuthorityResponse : AuthorityResponse + { + /// + [MemberNotNullWhen(true, nameof(IsNoContent))] + public override bool Success => base.Success; + + /// + /// Checks if a the is a no content result. Only set on . + /// + [MemberNotNullWhen(false, nameof(Result))] + [MemberNotNullWhen(false, nameof(Result))] + public bool? IsNoContent => Success ? Result == null : null; + + /// + /// The success . + /// + public TResult? Result { get; } + + /// + /// The for generating the s. + /// + public HttpSuccessResponse? SuccessResponse { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse httpResponse) + : base(errorMessage, httpResponse) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorityResponse(TResult result, HttpSuccessResponse httpResponse = HttpSuccessResponse.Ok) + { + Result = result ?? throw new ArgumentNullException(nameof(result)); + SuccessResponse = httpResponse; + } + + /// + /// Initializes a new instance of the class. + /// + /// This generates an HTTP 204 response. + public AuthorityResponse() + { + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..a25a04e2789 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; + +using Tgstation.Server.Host.GraphQL; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + sealed class GraphQLAuthorityInvoker : AuthorityInvokerBase, IGraphQLAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Throws a for errored s. + /// + /// The potentially errored . + /// If an error should be raised for and failures. + static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing) + { + if (authorityResponse.Success + || ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound + || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing)) + return; + + var fallbackString = authorityResponse.FailureResponse.ToString()!; + throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString); + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public GraphQLAuthorityInvoker(TAuthority authority) + : base(authority) + { + } + + /// + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, true); + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, false); + return authorityResponse.Result; + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, false); + var result = authorityResponse.Result; + if (result == null) + return default; + + return result.ToApi(); + } + + /// + ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + => ((IGraphQLAuthorityInvoker)this).InvokeAllowMissing(authorityInvoker)!; + + /// + ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + => ((IGraphQLAuthorityInvoker)this).InvokeTransformableAllowMissing(authorityInvoker)!; + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs new file mode 100644 index 00000000000..5aa3c18089d --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs @@ -0,0 +1,63 @@ +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Indicates the type of HTTP status code an failing should generate. + /// + public enum HttpFailureResponse + { + /// + /// HTTP 400. + /// + BadRequest, + + /// + /// HTTP 401. + /// + Unauthorized, + + /// + /// HTTP 403. + /// + Forbidden, + + /// + /// HTTP 404. + /// + NotFound, + + /// + /// HTTP 406. + /// + NotAcceptable, + + /// + /// HTTP 409. + /// + Conflict, + + /// + /// HTTP 410. + /// + Gone, + + /// + /// HTTP 422. + /// + UnprocessableEntity, + + /// + /// HTTP 424. + /// + FailedDependency, + + /// + /// HTTP 429. + /// + RateLimited, + + /// + /// HTTP 501. + /// + NotImplemented, + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs new file mode 100644 index 00000000000..cb7df2a963e --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Indicates the type of HTTP status code a successful should generate. + /// + public enum HttpSuccessResponse + { + /// + /// HTTP 200. + /// + Ok, + + /// + /// HTTP 201. + /// + Created, + + /// + /// HTTP 202. + /// + Accepted, + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs new file mode 100644 index 00000000000..e9b86411f8b --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs @@ -0,0 +1,11 @@ +#pragma warning disable CA1040 // it's helpful to have a common base class that indicates something is an authority, may remove + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Business logic for interating with the server. + /// + public interface IAuthority + { + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..9b49bd9ddab --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; + +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Invokes s. + /// + /// The invoked. + public interface IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// 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(); + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..680dc98a4ef --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; + +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Extensions; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + sealed class RestAuthorityInvoker : AuthorityInvokerBase, IRestAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Create an for a given successfuly. + /// + /// The to use. + /// A transforming the from the into the . + /// The . + /// An for the . + /// The result returned in the . + /// The REST API result model built from . + static IActionResult CreateSuccessfulActionResult(ApiController controller, Func resultTransformer, AuthorityResponse authorityResponse) + where TApiModel : notnull + { + if (authorityResponse.IsNoContent!.Value) + return controller.NoContent(); + + var successResponse = authorityResponse.SuccessResponse; + var result = resultTransformer(authorityResponse.Result!); + return successResponse switch + { + HttpSuccessResponse.Ok => controller.Json(result), + HttpSuccessResponse.Created => controller.Created(result), + HttpSuccessResponse.Accepted => controller.Accepted(result), + _ => throw new InvalidOperationException($"Invalid {nameof(HttpSuccessResponse)}: {successResponse}"), + }; + } + + /// + /// Create an for a given if it is erroring. + /// + /// The to use. + /// The . + /// An if the is not successful, otherwise. + static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse) + { + if (authorityResponse.Success) + return null; + + var errorMessage = authorityResponse.ErrorMessage; + var failureResponse = authorityResponse.FailureResponse; + return failureResponse switch + { + HttpFailureResponse.BadRequest => controller.BadRequest(errorMessage), + HttpFailureResponse.Unauthorized => controller.Unauthorized(errorMessage), + HttpFailureResponse.Forbidden => controller.Forbid(), + HttpFailureResponse.NotFound => controller.NotFound(errorMessage), + HttpFailureResponse.NotAcceptable => controller.StatusCode(HttpStatusCode.NotAcceptable, errorMessage), + HttpFailureResponse.Conflict => controller.Conflict(errorMessage), + HttpFailureResponse.Gone => controller.StatusCode(HttpStatusCode.Gone, errorMessage), + HttpFailureResponse.UnprocessableEntity => controller.UnprocessableEntity(errorMessage), + HttpFailureResponse.FailedDependency => controller.StatusCode(HttpStatusCode.FailedDependency, errorMessage), + HttpFailureResponse.RateLimited => controller.StatusCode(HttpStatusCode.TooManyRequests, errorMessage), + HttpFailureResponse.NotImplemented => controller.StatusCode(HttpStatusCode.NotImplemented, errorMessage), + _ => throw new InvalidOperationException($"Invalid {nameof(HttpFailureResponse)}: {failureResponse}"), + }; + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public RestAuthorityInvoker(TAuthority authority) + : base(authority) + { + } + + /// + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); + } + + /// + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + return CreateSuccessfulActionResult(controller, result => result, authorityResponse); + } + + /// + async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..edface219a8 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// Invokes s from GraphQL endpoints. + /// + /// The invoked. + public interface IGraphQLAuthorityInvoker : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Invoke a method with no success result. + /// + /// The returning a resulting in the . + /// A representing the running operation. + ValueTask Invoke(Func> authorityInvoker); + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the return value. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeAllowMissing(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the return value. + /// The for converting s to s. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); + + /// + /// Invoke a method and get the non-nullable result. + /// + /// The . + /// The resulting of the return value. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the non-nullable result. + /// + /// The . + /// The resulting of the return value. + /// The for converting s to s. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeTransformable(Func>> authorityInvoker) + where TResult : notnull, 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 new file mode 100644 index 00000000000..de0258db404 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for authenticating with the server. + /// + public interface ILoginAuthority : IAuthority + { + /// + /// Attempt to login to the server with the current crentials. + /// + /// The for the operation. + /// A resulting in a and . + ValueTask> AttemptLogin(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs new file mode 100644 index 00000000000..bb10fed383c --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs @@ -0,0 +1,26 @@ +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 +{ + /// + /// for managing s. + /// + public interface IPermissionSetAuthority : IAuthority + { + /// + /// Gets the with a given . + /// + /// The to lookup. + /// The of . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + ValueTask> 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 new file mode 100644 index 00000000000..2cb05a2b9a6 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// Invokes methods and generates responses. + /// + /// The type of . + public interface IRestAuthorityInvoker : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Invoke a method with no success result. + /// + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(ApiController controller, Func> authorityInvoker); + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the . + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(ApiController controller, Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The returned REST . + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + 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 new file mode 100644 index 00000000000..b0a67681cd2 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -0,0 +1,76 @@ +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 +{ + /// + /// for managing s. + /// + public interface IUserAuthority : IAuthority + { + /// + /// Gets the currently authenticated user. + /// + /// The for the operation. + /// A resulting in a . + [TgsAuthorize] + ValueTask> Read(CancellationToken cancellationToken); + + /// + /// Gets the with a given . + /// + /// The of the . + /// 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); + + /// + /// 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); + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// A of s. + [TgsAuthorize(AdministrationRights.ReadUsers)] + IQueryable Queryable(bool includeJoins); + + /// + /// Creates a . + /// + /// 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( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken); + + /// + /// Updates a . + /// + /// The . + /// The for the operation. + /// A resulting in am for the created . + [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)] + ValueTask> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs new file mode 100644 index 00000000000..28113b95f5e --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -0,0 +1,71 @@ +using System.Linq; +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 +{ + /// + /// for managing s. + /// + public interface IUserGroupAuthority : IAuthority + { + /// + /// Gets the current . + /// + /// A resulting in a . + ValueTask> Read(); + + /// + /// Gets the with a given . + /// + /// The of the . + /// If related entities should be loaded. + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// A of s. + [TgsAuthorize(AdministrationRights.ReadUsers)] + IQueryable Queryable(bool includeJoins); + + /// + /// Create a . + /// + /// The created 's . + /// The created 's . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); + + /// + /// Updates a . + /// + /// The of the to update. + /// The optional new for the . + /// The optional new for the . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> 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. + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask DeleteEmpty(long id, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs new file mode 100644 index 00000000000..7559ac023ae --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -0,0 +1,300 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class LoginAuthority : AuthorityBase, ILoginAuthority + { + /// + /// The for the . + /// + readonly IApiHeadersProvider apiHeadersProvider; + + /// + /// The for the . + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the . + /// + readonly IOAuthProviders oAuthProviders; + + /// + /// The for the . + /// + readonly ITokenFactory tokenFactory; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly IIdentityCache identityCache; + + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + + /// + /// Generate an for a given . + /// + /// The to generate a response for. + /// A new, errored . + static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) + => new( + new ErrorMessageResponse(ErrorCode.BadHeaders) + { + AdditionalData = headersException.Message, + }, + headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept) + ? HttpFailureResponse.NotAcceptable + : HttpFailureResponse.BadRequest); + + /// + /// Select the details needed to generate a from a given . + /// + /// The of s. + /// The for the operation. + /// A resulting in the returned after selecting, if any. + static async ValueTask SelectUserInfoFromQuery(IQueryable query, CancellationToken cancellationToken) + { + var users = await query + .ToListAsync(cancellationToken); + + // Pick the DB user first + var user = users + .OrderByDescending(dbUser => dbUser.SystemIdentifier == null) + .FirstOrDefault(); + + return user; + } + + /// + /// 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 . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public LoginAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IApiHeadersProvider apiHeadersProvider, + ISystemIdentityFactory systemIdentityFactory, + IOAuthProviders oAuthProviders, + ITokenFactory tokenFactory, + ICryptographySuite cryptographySuite, + IIdentityCache identityCache, + ISessionInvalidationTracker sessionInvalidationTracker) + : base( + authenticationContext, + databaseContext, + logger) + { + this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); + this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); + this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker)); + } + + /// + public async ValueTask> AttemptLogin(CancellationToken cancellationToken) + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + if (headers.IsTokenAuthentication) + return BadRequest(ErrorCode.TokenWithToken); + + var oAuthLogin = headers.OAuthProvider.HasValue; + + ISystemIdentity? systemIdentity = null; + if (!oAuthLogin) + try + { + // trust the system over the database because a user's name can change while still having the same SID + systemIdentity = await systemIdentityFactory.CreateSystemIdentity(headers.Username!, headers.Password!, cancellationToken); + } + catch (NotImplementedException) + { + // Intentionally suppressed + } + + using (systemIdentity) + { + // Get the user from the database + IQueryable query = DatabaseContext.Users.AsQueryable(); + if (oAuthLogin) + { + var oAuthProvider = headers.OAuthProvider!.Value; + string? externalUserId; + try + { + var validator = oAuthProviders + .GetValidator(oAuthProvider); + + if (validator == null) + return BadRequest(ErrorCode.OAuthProviderDisabled); + + externalUserId = await validator + .ValidateResponseCode(headers.OAuthCode!, cancellationToken); + + Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); + } + catch (Octokit.RateLimitExceededException ex) + { + return RateLimit(ex); + } + + if (externalUserId == null) + return Unauthorized(); + + query = query.Where( + x => x.OAuthConnections!.Any( + y => y.Provider == oAuthProvider + && y.ExternalUserId == externalUserId)); + } + else + { + var canonicalUserName = User.CanonicalizeName(headers.Username!); + if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName)) + return Unauthorized(); + + if (systemIdentity == null) + query = query.Where(x => x.CanonicalName == canonicalUserName); + else + query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid); + } + + var user = await SelectUserInfoFromQuery(query, cancellationToken); + + // No user? You're not allowed + if (user == null) + return Unauthorized(); + + // A system user may have had their name AND password changed to one in our DB... + // Or a DB user was created that had the same user/pass as a system user + // Dumb admins... + // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!! + // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault + var originalHash = user.PasswordHash; + var isLikelyDbUser = originalHash != null; + var usingSystemIdentity = systemIdentity != null && !isLikelyDbUser; + if (!oAuthLogin) + if (!usingSystemIdentity) + { + // DB User password check and update + if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!)) + return Unauthorized(); + if (user.PasswordHash != originalHash) + { + Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id); + var updatedUser = new User + { + Id = user.Id, + }; + DatabaseContext.Users.Attach(updatedUser); + updatedUser.PasswordHash = user.PasswordHash; + await DatabaseContext.Save(cancellationToken); + } + } + else + { + var usernameMismatch = systemIdentity!.Username != user.Name; + if (isLikelyDbUser || usernameMismatch) + { + DatabaseContext.Users.Attach(user); + if (usernameMismatch) + { + // System identity username change update + Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id); + user.Name = systemIdentity.Username; + user.CanonicalName = User.CanonicalizeName(user.Name); + } + + if (isLikelyDbUser) + { + // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 + Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id); + user.PasswordHash = null; + sessionInvalidationTracker.UserModifiedInvalidateSessions(user); + } + + await DatabaseContext.Save(cancellationToken); + } + } + + // Now that the bookeeping is done, tell them to fuck off if necessary + if (!user.Enabled!.Value) + { + Logger.LogTrace("Not logging in disabled user {userId}.", user.Id); + return Forbid(); + } + + var token = tokenFactory.CreateToken(user, oAuthLogin); + var payload = new LoginPayload + { + Bearer = token, + User = ((IApiTransformable)user).ToApi(), + }; + + if (usingSystemIdentity) + await CacheSystemIdentity(systemIdentity!, user, payload); + + Logger.LogDebug("Successfully logged in user {userId}!", user.Id); + + return new AuthorityResponse(payload); + } + } + + /// + /// Add a given to the . + /// + /// The to cache. + /// The the was generated for. + /// The for the successful login. + /// A representing the running operation. + private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, LoginPayload loginPayload) + { + // expire the identity slightly after the auth token in case of lag + var identExpiry = loginPayload.ToApi().ParseJwt().ValidTo; + identExpiry += tokenFactory.ValidationParameters.ClockSkew; + identExpiry += TimeSpan.FromSeconds(15); + await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs new file mode 100644 index 00000000000..12e63a9ce6a --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class PermissionSetAuthority : AuthorityBase, IPermissionSetAuthority + { + /// + /// The for the . + /// + readonly IPermissionSetsDataLoader permissionSetsDataLoader; + + /// + /// Implements . + /// + /// The of IDs and their s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static async ValueTask> GetPermissionSets( + IReadOnlyList<(long Id, PermissionSetLookupType LookupType)> ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + var idLookups = new List(ids.Count); + var userIdLookups = new List(ids.Count); + var groupIdLookups = new List(ids.Count); + + foreach (var (id, lookupType) in ids) + switch (lookupType) + { + case PermissionSetLookupType.Id: + idLookups.Add(id); + break; + case PermissionSetLookupType.UserId: + userIdLookups.Add(id); + break; + case PermissionSetLookupType.GroupId: + groupIdLookups.Add(id); + break; + default: + throw new InvalidOperationException($"Invalid {nameof(PermissionSetLookupType)}: {lookupType}"); + } + + var selectedPermissionSets = await databaseContext + .PermissionSets + .Where(dbModel => idLookups.Contains(dbModel.Id!.Value) + || (dbModel.UserId.HasValue && userIdLookups.Contains(dbModel.UserId.Value)) + || (dbModel.GroupId.HasValue && groupIdLookups.Contains(dbModel.GroupId.Value))) + .ToListAsync(cancellationToken); + + var results = new Dictionary<(long Id, PermissionSetLookupType LookupType), PermissionSet>(selectedPermissionSets.Count * 2); + foreach (var permissionSet in selectedPermissionSets) + { + results.Add((permissionSet.Id!.Value, PermissionSetLookupType.Id), permissionSet); + if (permissionSet.GroupId.HasValue) + results.Add((permissionSet.GroupId.Value, PermissionSetLookupType.GroupId), permissionSet); + if (permissionSet.UserId.HasValue) + results.Add((permissionSet.UserId.Value, PermissionSetLookupType.UserId), permissionSet); + } + + return results; + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + public PermissionSetAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IPermissionSetsDataLoader permissionSetsDataLoader) + : base( + authenticationContext, + databaseContext, + logger) + { + this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader)); + } + + /// + public async ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken); + if (permissionSet == null) + return NotFound(); + + return new AuthorityResponse(permissionSet); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs b/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs new file mode 100644 index 00000000000..12925dbf8fa --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Authority +{ + /// + /// Indicates the type of to lookup on a . + /// + public enum PermissionSetLookupType + { + /// + /// Lookup the of the . + /// + Id, + + /// + /// Lookup the of the . + /// + UserId, + + /// + /// Lookup the of the . + /// + GroupId, + } +} diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs new file mode 100644 index 00000000000..753a180d301 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -0,0 +1,630 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using HotChocolate.Subscriptions; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +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.Common.Extensions; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class UserAuthority : AuthorityBase, IUserAuthority + { + /// + /// The for the . + /// + readonly IUsersDataLoader usersDataLoader; + + /// + /// The for the . + /// + readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader; + + /// + /// The for the . + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + + /// + /// The for the . + /// + readonly ITopicEventSender topicEventSender; + + /// + /// The of for the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + + /// + /// Implements the . + /// + /// The of s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static Task> GetUsers( + IReadOnlyList ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + return databaseContext + .Users + .AsQueryable() + .Where(x => ids.Contains(x.Id!.Value)) + .ToDictionaryAsync(user => user.Id!.Value, cancellationToken); + } + + /// + /// Implements the . + /// + /// The of s to load the OAuthConnections for. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static async ValueTask> GetOAuthConnections( + IReadOnlyList userIds, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userIds); + ArgumentNullException.ThrowIfNull(databaseContext); + + var list = await databaseContext + .OAuthConnections + .AsQueryable() + .Where(x => userIds.Contains(x.User!.Id!.Value)) + .ToListAsync(cancellationToken); + + return list.ToLookup( + oauthConnection => oauthConnection.UserId, + x => new GraphQL.Types.OAuthConnection(x.ExternalUserId!, x.Provider)); + } + + /// + /// Check if a given has a valid specified. + /// + /// The to check. + /// If this is a new . + /// if is valid, an errored otherwise. + 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); + + model.Name = model.Name?.Trim(); + if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) + 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 . + /// The value of . + /// 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, + IOAuthConnectionsDataLoader oAuthConnectionsDataLoader, + ISystemIdentityFactory systemIdentityFactory, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + ICryptographySuite cryptographySuite, + ISessionInvalidationTracker sessionInvalidationTracker, + ITopicEventSender topicEventSender, + IOptionsSnapshot generalConfigurationOptions) + : base( + authenticationContext, + databaseContext, + logger) + { + this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader)); + this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); + 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.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + /// Checks if a should return a bad request . + /// + /// The to check. + /// If a zero-length indicates and OAuth only user. + /// The output failing , if any. + /// if checks failed and was populated, otherwise. + static bool BadCreateRequestChecks( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + [NotNullWhen(true)] out AuthorityResponse? failResponse) + { + if (createRequest.OAuthConnections?.Any(x => x == null) == true) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + + var hasNonNullPassword = createRequest.Password != null; + var hasNonNullSystemIdentifier = createRequest.SystemIdentifier != null; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; + if ((hasNonNullPassword && hasNonNullSystemIdentifier) + || (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections)) + { + failResponse = BadRequest(ErrorCode.UserMismatchPasswordSid); + return true; + } + + var hasZeroLengthPassword = createRequest.Password?.Length == 0; + if (needZeroLengthPasswordWithOAuthConnections.HasValue) + { + if (needZeroLengthPasswordWithOAuthConnections.Value) + { + if (createRequest.OAuthConnections == null) + throw new InvalidOperationException($"Expected {nameof(UserCreateRequest.OAuthConnections)} to be set here!"); + + if (createRequest.OAuthConnections.Count == 0) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + } + else if (hasZeroLengthPassword) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + } + + if (createRequest.Group != null && createRequest.PermissionSet != null) + { + failResponse = BadRequest(ErrorCode.UserGroupAndPermissionSet); + return true; + } + + createRequest.Name = createRequest.Name?.Trim(); + if (createRequest.Name?.Length == 0) + createRequest.Name = null; + + if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null)) + { + failResponse = BadRequest(ErrorCode.UserMismatchNameSid); + return true; + } + + failResponse = CheckValidName(createRequest, true); + return failResponse != null; + } + + /// + public ValueTask> Read(CancellationToken cancellationToken) + => ValueTask.FromResult(new AuthorityResponse(AuthenticationContext.User)); + + /// + 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(); + + User? user; + if (includeJoins) + { + var queryable = Queryable(true, true); + + user = await queryable.FirstOrDefaultAsync( + dbModel => dbModel.Id == id, + cancellationToken); + } + else + user = await usersDataLoader.LoadAsync(id, cancellationToken); + + if (user == default) + return NotFound(); + + if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); + + 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> Create( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken) + { + 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.EditOwnOAuthConnections); + + 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.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 (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotOAuth); + + if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); + + originalUser.OAuthConnections.Clear(); + foreach (var updatedConnection in model.OAuthConnections) + originalUser.OAuthConnections.Add(new Models.OAuthConnection + { + Provider = updatedConnection.Provider, + ExternalUserId = updatedConnection.ExternalUserId, + }); + } + + if (model.Group != null) + { + 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 (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) + { + 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(); + } + + /// + /// Send topics through the indicating a given was created or updated. + /// + /// The that was created or updated. + /// A representing the running operation. + ValueTask SendUserUpdatedTopics(User user) + => ValueTaskExtensions.WhenAll( + GraphQL.Subscriptions.UserSubscriptions.UserUpdatedTopics( + user.Require(x => x.Id)) + .Select(topic => topicEventSender.SendAsync( + topic, + ((IApiTransformable)user).ToApi(), + CancellationToken.None))); // DCT: Operation should always run + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// If the with the should be included in results. + /// A of s. + IQueryable Queryable(bool includeJoins, bool allowSystemUser) + { + var tgsUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName); + var queryable = DatabaseContext + .Users + .AsQueryable(); + + if (!allowSystemUser) + queryable = queryable + .Where(user => user.CanonicalName != tgsUserCanonicalName); + + if (includeJoins) + queryable = queryable + .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.Group!) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet); + + return queryable; + } + + /// + /// Creates a new from a given . + /// + /// The to use as a template. + /// The for the operation. + /// A resulting in a new on success, if the requested did not exist. + async ValueTask CreateNewUserFromModel(Api.Models.Internal.UserApiBase model, CancellationToken cancellationToken) + { + Models.PermissionSet? permissionSet = null; + UserGroup? group = null; + if (model.Group != null) + group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + else + permissionSet = new Models.PermissionSet + { + AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, + }; + + return new User + { + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = AuthenticationContext.User, + Enabled = model.Enabled ?? false, + PermissionSet = permissionSet, + Group = group, + Name = model.Name, + SystemIdentifier = model.SystemIdentifier, + OAuthConnections = model + .OAuthConnections + ?.Select(x => new Models.OAuthConnection + { + Provider = x.Provider, + ExternalUserId = x.ExternalUserId, + }) + .ToList() + ?? new List(), + }; + } + + /// + /// Attempt to change the password of a given . + /// + /// The user to update. + /// 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) + { + newPassword ??= String.Empty; + if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength) + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.UserPasswordLength) + { + AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}", + }, + HttpFailureResponse.BadRequest); + cryptographySuite.SetUserPassword(dbUser, newPassword, newUser); + return null; + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs new file mode 100644 index 00000000000..4b9fcdb4014 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +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.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class UserGroupAuthority : AuthorityBase, IUserGroupAuthority + { + /// + /// The for the . + /// + readonly IUserGroupsDataLoader userGroupsDataLoader; + + /// + /// The of the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + + /// + /// Implements the . + /// + /// The of s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static Task> GetUserGroups( + IReadOnlyList ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + return databaseContext + .Groups + .Where(group => ids.Contains(group.Id!.Value)) + .ToDictionaryAsync(userGroup => userGroup.Id!.Value, cancellationToken); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + public UserGroupAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IUserGroupsDataLoader userGroupsDataLoader, + IOptionsSnapshot generalConfigurationOptions) + : base( + authenticationContext, + databaseContext, + logger) + { + this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); + this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + UserGroup? userGroup; + if (includeJoins) + userGroup = await Queryable(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 ValueTask> Read() + { + var group = AuthenticationContext.User!.Group; + if (group == null) + return ValueTask.FromResult(Gone()); + + return ValueTask.FromResult(new AuthorityResponse(group)); + } + + /// + public IQueryable Queryable(bool includeJoins) + { + var queryable = DatabaseContext + .Groups + .AsQueryable(); + + if (includeJoins) + queryable = queryable + .Include(x => x.Users) + .Include(x => x.PermissionSet); + + 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/Engine/OpenDreamInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs index ac4c0964e92..f37c4489b14 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs @@ -251,10 +251,8 @@ public override async ValueTask Install(EngineVersion version, string installPat await Task.WhenAll(dirsMoveTasks.Concat(filesMoveTask)); } - var dotnetPath = await DotnetHelper.GetDotnetPath(platformIdentifier, IOManager, cancellationToken); - if (dotnetPath == null) - throw new JobException(ErrorCode.OpenDreamCantFindDotnet); - + var dotnetPath = (await DotnetHelper.GetDotnetPath(platformIdentifier, IOManager, cancellationToken)) + ?? throw new JobException(ErrorCode.OpenDreamCantFindDotnet); const string DeployDir = "tgs_deploy"; int? buildExitCode = null; await HandleExtremelyLongPathOperation( diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index bad2e6c10f1..9b2b058010b 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -38,12 +38,12 @@ public abstract class ApiController : ApiControllerBase /// /// Default size of results. /// - private const ushort DefaultPageSize = 10; + public const ushort DefaultPageSize = 10; /// /// Maximum size of results. /// - private const ushort MaximumPageSize = 100; + public const ushort MaximumPageSize = 100; /// /// The for the operation. @@ -186,23 +186,6 @@ protected ApiController( /// A with an appropriate . protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent)); - /// - /// Generic 401 response. - /// - /// An with . - protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null); - - /// - /// Generic 501 response. - /// - /// The that was thrown. - /// An with . - protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex) - { - Logger.LogTrace(ex, "System identities not implemented!"); - return this.StatusCode(HttpStatusCode.NotImplemented, new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity)); - } - /// /// Strongly type calls to . /// @@ -210,13 +193,6 @@ protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex) /// A with the given . protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode); - /// - /// Generic 201 response with a given . - /// - /// The accompanying API payload. - /// A with the given . - protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); - /// /// 429 response for a given . /// @@ -301,7 +277,7 @@ protected ValueTask Paginated( int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken) - where TModel : IApiTransformable + where TModel : ILegacyApiTransformable => PaginatedImpl( queryGenerator, resultTransformer, @@ -312,7 +288,7 @@ protected ValueTask Paginated( /// /// Generates a paginated response. /// - /// The of model being generated. If different from , must implement for . + /// 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 to transform the s after being queried. @@ -361,15 +337,15 @@ async ValueTask PaginatedImpl( else { totalResults = paginationResult.Results.Count(); - pagedResults = queriedResults.ToList(); + pagedResults = [.. queriedResults]; } ICollection finalResults; - if (typeof(TModel) == typeof(TResultModel)) - finalResults = (List)(object)pagedResults; // clearly a safe cast + if (typeof(TResultModel).IsAssignableFrom(typeof(TModel))) + finalResults = pagedResults.Cast().ToList(); // clearly a safe cast else finalResults = pagedResults - .OfType>() + .Cast>() .Select(x => x.ToApi()) .ToList(); diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 626a0a71b11..08e499f233c 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -14,12 +13,13 @@ using Octokit; using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; @@ -35,31 +35,11 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.ApiRoot)] public sealed class ApiRootController : ApiController { - /// - /// The for the . - /// - readonly ITokenFactory tokenFactory; - - /// - /// The for the . - /// - readonly ISystemIdentityFactory systemIdentityFactory; - - /// - /// The for the . - /// - readonly ICryptographySuite cryptographySuite; - /// /// The for the . /// readonly IAssemblyInformationProvider assemblyInformationProvider; - /// - /// The for the . - /// - readonly IIdentityCache identityCache; - /// /// The for the . /// @@ -80,6 +60,11 @@ public sealed class ApiRootController : ApiController /// readonly IServerControl serverControl; + /// + /// The for the . + /// + readonly IRestAuthorityInvoker loginAuthority; + /// /// The for the . /// @@ -90,11 +75,7 @@ public sealed class ApiRootController : ApiController /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . /// The value of . - /// The value of . /// The value of . /// The value of . /// The value of . @@ -102,21 +83,19 @@ public sealed class ApiRootController : ApiController /// The containing the value of . /// The for the . /// The for the . + /// The value of . public ApiRootController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - ITokenFactory tokenFactory, - ISystemIdentityFactory systemIdentityFactory, - ICryptographySuite cryptographySuite, IAssemblyInformationProvider assemblyInformationProvider, - IIdentityCache identityCache, IOAuthProviders oAuthProviders, IPlatformIdentifier platformIdentifier, ISwarmService swarmService, IServerControl serverControl, IOptions generalConfigurationOptions, ILogger logger, - IApiHeadersProvider apiHeadersProvider) + IApiHeadersProvider apiHeadersProvider, + IRestAuthorityInvoker loginAuthority) : base( databaseContext, authenticationContext, @@ -124,16 +103,13 @@ public ApiRootController( logger, false) { - this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); - this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); - this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); - this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService)); this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.loginAuthority = loginAuthority ?? throw new ArgumentNullException(nameof(loginAuthority)); } /// @@ -202,172 +178,15 @@ public IActionResult ServerInfo() [HttpPost] [ProducesResponseType(typeof(TokenResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] -#pragma warning disable CA1506 // TODO: Decomplexify - public async ValueTask CreateToken(CancellationToken cancellationToken) + public ValueTask CreateToken(CancellationToken cancellationToken) { if (ApiHeaders == null) { Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); - return HeadersIssue(ApiHeadersProvider.HeadersException!); + return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!)); } - if (ApiHeaders.IsTokenAuthentication) - return BadRequest(new ErrorMessageResponse(ErrorCode.TokenWithToken)); - - var oAuthLogin = ApiHeaders.OAuthProvider.HasValue; - - ISystemIdentity? systemIdentity = null; - if (!oAuthLogin) - try - { - // trust the system over the database because a user's name can change while still having the same SID - systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username!, ApiHeaders.Password!, cancellationToken); - } - catch (NotImplementedException) - { - // Intentionally suppressed - } - - using (systemIdentity) - { - // Get the user from the database - IQueryable query = DatabaseContext.Users.AsQueryable(); - if (oAuthLogin) - { - var oAuthProvider = ApiHeaders.OAuthProvider!.Value; - string? externalUserId; - try - { - var validator = oAuthProviders - .GetValidator(oAuthProvider); - - if (validator == null) - return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled)); - - externalUserId = await validator - .ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken); - - Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); - } - catch (RateLimitExceededException ex) - { - return RateLimit(ex); - } - - if (externalUserId == null) - return Unauthorized(); - - query = query.Where( - x => x.OAuthConnections!.Any( - y => y.Provider == oAuthProvider - && y.ExternalUserId == externalUserId)); - } - else - { - var canonicalUserName = Models.User.CanonicalizeName(ApiHeaders.Username!); - if (canonicalUserName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Unauthorized(); - - if (systemIdentity == null) - query = query.Where(x => x.CanonicalName == canonicalUserName); - else - query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid); - } - - var users = await query - .Select(x => new Models.User - { - Id = x.Id, - PasswordHash = x.PasswordHash, - Enabled = x.Enabled, - Name = x.Name, - SystemIdentifier = x.SystemIdentifier, - }) - .ToListAsync(cancellationToken); - - // Pick the DB user first - var user = users - .OrderByDescending(dbUser => dbUser.SystemIdentifier == null) - .FirstOrDefault(); - - // No user? You're not allowed - if (user == null) - return Unauthorized(); - - // A system user may have had their name AND password changed to one in our DB... - // Or a DB user was created that had the same user/pass as a system user - // Dumb admins... - // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!! - // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault - var originalHash = user.PasswordHash; - var isLikelyDbUser = originalHash != null; - bool usingSystemIdentity = systemIdentity != null && !isLikelyDbUser; - if (!oAuthLogin) - if (!usingSystemIdentity) - { - // DB User password check and update - if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, ApiHeaders.Password!)) - return Unauthorized(); - if (user.PasswordHash != originalHash) - { - Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id); - var updatedUser = new Models.User - { - Id = user.Id, - }; - DatabaseContext.Users.Attach(updatedUser); - updatedUser.PasswordHash = user.PasswordHash; - await DatabaseContext.Save(cancellationToken); - } - } - else - { - var usernameMismatch = systemIdentity!.Username != user.Name; - if (isLikelyDbUser || usernameMismatch) - { - DatabaseContext.Users.Attach(user); - if (isLikelyDbUser) - { - // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 - Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id); - user.PasswordHash = null; - user.LastPasswordUpdate = DateTimeOffset.UtcNow; - } - - if (usernameMismatch) - { - // System identity username change update - Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id); - user.Name = systemIdentity.Username; - user.CanonicalName = Models.User.CanonicalizeName(user.Name); - } - - await DatabaseContext.Save(cancellationToken); - } - } - - // Now that the bookeeping is done, tell them to fuck off if necessary - if (!user.Enabled!.Value) - { - Logger.LogTrace("Not logging in disabled user {userId}.", user.Id); - return Forbid(); - } - - var token = tokenFactory.CreateToken(user, oAuthLogin); - if (usingSystemIdentity) - { - // expire the identity slightly after the auth token in case of lag - var identExpiry = token.ParseJwt().ValidTo; - identExpiry += tokenFactory.ValidationParameters.ClockSkew; - identExpiry += TimeSpan.FromSeconds(15); - await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); - } - - Logger.LogDebug("Successfully logged in user {userId}!", user.Id); - - return Json(token); - } + return loginAuthority.InvokeTransformable(this, authority => authority.AttemptLogin(cancellationToken)); } -#pragma warning restore CA1506 } } diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 8232be095c0..f0609467844 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -14,6 +14,7 @@ using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -266,7 +267,7 @@ public async ValueTask CreateDirectory([FromBody] ConfigurationFi return result.Value ? Json(resultModel) - : Created(resultModel); + : this.Created(resultModel); }); } catch (IOException e) diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 4b25eac1437..bada9ace1ba 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -258,7 +258,7 @@ await permissionsUpdateNotifyee.InstancePermissionSetCreated( var api = newInstance.ToApi(); api.Accessible = true; // instances are always accessible by their creator - return attached ? Json(api) : Created(api); + return attached ? Json(api) : this.Created(api); } /// diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 58b0170ad40..5b4ac2a9c44 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -123,7 +123,7 @@ public async ValueTask Create([FromBody] InstancePermissionSetReq // needs to be set for next call dbUser.PermissionSet = existingPermissionSet; await permissionsUpdateNotifyee.InstancePermissionSetCreated(dbUser, cancellationToken); - return Created(dbUser.ToApi()); + return this.Created(dbUser.ToApi()); } #pragma warning restore CA1506 diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 6b4a6f3175a..533aec872d7 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -205,7 +205,7 @@ await databaseContextFactory.UseContext( api.Reference = model.Reference; api.ActiveJob = job.ToApi(); - return Created(api); + return this.Created(api); }); } @@ -326,7 +326,7 @@ public async ValueTask Read(CancellationToken cancellationToken) { // user may have fucked with the repo manually, do what we can await DatabaseContext.Save(cancellationToken); - return Created(api); + return this.Created(api); } return Json(api); diff --git a/src/Tgstation.Server.Host/Controllers/RootController.cs b/src/Tgstation.Server.Host/Controllers/RootController.cs index 577fecf1c64..fcf2bd239b2 100644 --- a/src/Tgstation.Server.Host/Controllers/RootController.cs +++ b/src/Tgstation.Server.Host/Controllers/RootController.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tgstation.Server.Api; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.System; @@ -62,6 +63,11 @@ public sealed class RootController : Controller /// readonly ControlPanelConfiguration controlPanelConfiguration; + /// + /// The for the . + /// + readonly InternalConfiguration internalConfiguration; + /// /// Gets a giving the and action names for a given . /// @@ -92,13 +98,15 @@ public sealed class RootController : Controller /// The value of . /// The containing the value of . /// The containing the value of . + /// The containing the value of . public RootController( IAssemblyInformationProvider assemblyInformationProvider, IPlatformIdentifier platformIdentifier, IWebHostEnvironment hostEnvironment, ILogger logger, IOptions generalConfigurationOptions, - IOptions controlPanelConfigurationOptions) + IOptions controlPanelConfigurationOptions, + IOptionsSnapshot internalConfigurationOptions) { this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); @@ -106,6 +114,7 @@ public RootController( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions)); + internalConfiguration = internalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(internalConfigurationOptions)); } /// @@ -120,19 +129,26 @@ public IActionResult Index() var apiDocsEnabled = generalConfiguration.HostApiDocumentation; var controlPanelRoute = $"{ControlPanelController.ControlPanelRoute.TrimStart('/')}/"; - if (panelEnabled ^ apiDocsEnabled) + if (panelEnabled && !apiDocsEnabled) + return Redirect(controlPanelRoute); + + Dictionary? links = null; + + if (panelEnabled || apiDocsEnabled) + { + links = new Dictionary(); + if (panelEnabled) - return Redirect(controlPanelRoute); - else - return Redirect(SwaggerConfiguration.DocumentationSiteRouteExtension); + links.Add("Web Control Panel", controlPanelRoute); - Dictionary? links; - if (panelEnabled) - links = new Dictionary() + if (apiDocsEnabled) { - { "Web Control Panel", controlPanelRoute }, - { "API Documentation", SwaggerConfiguration.DocumentationSiteRouteExtension }, - }; + if (internalConfiguration.EnableGraphQL) + links.Add("GraphQL API Documentation", Routes.GraphQL); + + links.Add("REST API Documentation", SwaggerConfiguration.DocumentationSiteRouteExtension); + } + } else links = null; diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 68bccfa1692..f55218ef647 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -1,23 +1,19 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; 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.Configuration; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Utils; @@ -31,44 +27,23 @@ namespace Tgstation.Server.Host.Controllers public sealed class UserController : ApiController { /// - /// The for the . + /// The for the . /// - readonly ISystemIdentityFactory systemIdentityFactory; - - /// - /// The for the . - /// - readonly ICryptographySuite cryptographySuite; - - /// - /// The for the . - /// - readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; - - /// - /// The for the . - /// - readonly GeneralConfiguration generalConfiguration; + readonly IRestAuthorityInvoker userAuthority; /// /// Initializes a new instance of the class. /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . + /// The value of . /// The for the . - /// The containing the value of . /// The for the . public UserController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - ISystemIdentityFactory systemIdentityFactory, - ICryptographySuite cryptographySuite, - IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + IRestAuthorityInvoker userAuthority, ILogger logger, - IOptions generalConfigurationOptions, IApiHeadersProvider apiHeaders) : base( databaseContext, @@ -77,10 +52,7 @@ public UserController( logger, true) { - this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); - this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); - this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority)); } /// @@ -92,81 +64,15 @@ public UserController( /// created successfully. /// The requested system identifier could not be found. [HttpPut] - [TgsAuthorize(AdministrationRights.WriteUsers)] + [TgsRestAuthorize(nameof(IUserAuthority.Create))] [ProducesResponseType(typeof(UserResponse), 201)] -#pragma warning disable CA1502, CA1506 - public async ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(model); - - if (model.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - - if ((model.Password != null && model.SystemIdentifier != null) - || (model.Password == null && model.SystemIdentifier == null && (model.OAuthConnections?.Count > 0) != true)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchPasswordSid)); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserGroupAndPermissionSet)); - - model.Name = model.Name?.Trim(); - if (model.Name?.Length == 0) - model.Name = null; - - if (!(model.Name == null ^ model.SystemIdentifier == null)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchNameSid)); - - var fail = CheckValidName(model, true); - if (fail != null) - return fail; - - var totalUsers = await DatabaseContext - .Users - .AsQueryable() - .CountAsync(cancellationToken); - if (totalUsers >= generalConfiguration.UserLimit) - return Conflict(new ErrorMessageResponse(ErrorCode.UserLimitReached)); - - var dbUser = await CreateNewUserFromModel(model, cancellationToken); - if (dbUser == null) - return this.Gone(); - - if (model.SystemIdentifier != null) - try - { - using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken); - if (sysIdentity == null) - return this.Gone(); - dbUser.Name = sysIdentity.Username; - dbUser.SystemIdentifier = sysIdentity.Uid; - } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } - else if (!(model.Password?.Length == 0 && (model.OAuthConnections?.Count > 0) == true)) - { - var result = TrySetPassword(dbUser, model.Password!, true); - if (result != null) - return result; - } - - dbUser.CanonicalName = Models.User.CanonicalizeName(dbUser.Name!); - - DatabaseContext.Users.Add(dbUser); - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); - - return Created(dbUser.ToApi()); - } -#pragma warning restore CA1502, CA1506 + public ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Create(model, null, cancellationToken)); /// /// Update a . /// - /// The to update. + /// The . /// The for the operation. /// A resulting in the of the operation. /// updated successfully. @@ -174,181 +80,25 @@ public async ValueTask Create([FromBody] UserCreateRequest model, /// Requested does not exist. /// Requested does not exist. [HttpPost] - [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)] + [TgsRestAuthorize(nameof(IUserAuthority.Update))] [ProducesResponseType(typeof(UserResponse), 200)] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 404)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] -#pragma warning disable CA1502 // TODO: Decomplexify -#pragma warning disable CA1506 - public async ValueTask Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(model); - - if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(new ErrorMessageResponse(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.EditOwnOAuthConnections); - - 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.Group!) - .ThenInclude(x => x.PermissionSet) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser == default) - return NotFound(); - - if (originalUser.CanonicalName == Models.User.CanonicalizeName(Models.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; - 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; - originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; - } - - if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserSidChange)); - - if (model.Password != null) - { - if (originalUserHasSid) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchPasswordSid)); - - var result = TrySetPassword(originalUser, model.Password, false); - if (result != null) - return result; - } - - if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserNameChange)); - - bool userWasDisabled; - if (model.Enabled.HasValue) - { - userWasDisabled = originalUser.Require(x => x.Enabled) && !model.Enabled.Value; - if (userWasDisabled) - originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; - - originalUser.Enabled = model.Enabled.Value; - } - else - userWasDisabled = false; - - 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 (originalUser.CanonicalName == Models.User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(new ErrorMessageResponse(ErrorCode.AdminUserCannotOAuth)); - - if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(new ErrorMessageResponse(ErrorCode.CannotRemoveLastAuthenticationOption)); - - originalUser.OAuthConnections.Clear(); - foreach (var updatedConnection in model.OAuthConnections) - originalUser.OAuthConnections.Add(new Models.OAuthConnection - { - Provider = updatedConnection.Provider, - ExternalUserId = updatedConnection.ExternalUserId, - }); - } - - if (model.Group != null) - { - originalUser.Group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Group.Id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser.Group == default) - return this.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 (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; - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); - - if (userWasDisabled) - await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); - - // 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 - ? Json(originalUser.ToApi()) - : NoContent(); - } -#pragma warning restore CA1506 -#pragma warning restore CA1502 + public ValueTask Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Update(model, cancellationToken)); /// /// Get information about the current . /// - /// The of the operation. + /// The for the operation. + /// A resulting in the of the operation. /// The was retrieved successfully. [HttpGet] - [TgsAuthorize] + [TgsRestAuthorize(nameof(IUserAuthority.Read))] [ProducesResponseType(typeof(UserResponse), 200)] - public IActionResult Read() => Json(AuthenticationContext.User.ToApi()); + public ValueTask Read(CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Read(cancellationToken)); /// /// List all s in the server. @@ -359,21 +109,14 @@ public async ValueTask Update([FromBody] UserUpdateRequest model, /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( () => ValueTask.FromResult( new PaginatableResult( - DatabaseContext - .Users - .AsQueryable() - .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - .Include(x => x.CreatedBy) - .Include(x => x.PermissionSet) - .Include(x => x.OAuthConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) + userAuthority.InvokeQueryable( + authority => authority.Queryable(true)) .OrderBy(x => x.Id))), null, page, @@ -395,109 +138,14 @@ public ValueTask List([FromQuery] int? page, [FromQuery] int? pag public async ValueTask GetId(long id, CancellationToken cancellationToken) { if (id == AuthenticationContext.User.Id) - return Read(); + return await Read(cancellationToken); if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) return Forbid(); - var user = await DatabaseContext.Users - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.CreatedBy) - .Include(x => x.OAuthConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - if (user == default) - return NotFound(); - - if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Forbid(); - - return Json(user.ToApi()); - } - - /// - /// Creates a new from a given . - /// - /// The to use as a template. - /// The for the operation. - /// A resulting in a new on success, if the requested did not exist. - async ValueTask CreateNewUserFromModel(Api.Models.Internal.UserApiBase model, CancellationToken cancellationToken) - { - Models.PermissionSet? permissionSet = null; - UserGroup? group = null; - if (model.Group != null) - group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Group.Id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - else - permissionSet = new Models.PermissionSet - { - AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - return new User - { - CreatedAt = DateTimeOffset.UtcNow, - CreatedBy = AuthenticationContext.User, - Enabled = model.Enabled ?? false, - PermissionSet = permissionSet, - Group = group, - Name = model.Name, - SystemIdentifier = model.SystemIdentifier, - OAuthConnections = model - .OAuthConnections - ?.Select(x => new Models.OAuthConnection - { - Provider = x.Provider, - ExternalUserId = x.ExternalUserId, - }) - .ToList() - ?? new List(), - }; - } - - /// - /// Check if a given has a valid specified. - /// - /// The to check. - /// If this is a new . - /// if is valid, a otherwise. - BadRequestObjectResult? 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(new ErrorMessageResponse(ErrorCode.UserMissingName)); - - model.Name = model.Name?.Trim(); - if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserColonInName)); - return null; - } - - /// - /// Attempt to change the password of a given . - /// - /// The user to update. - /// The new password. - /// If this is for a new . - /// on success, if is too short. - BadRequestObjectResult? TrySetPassword(User dbUser, string newPassword, bool newUser) - { - newPassword ??= String.Empty; - if (newPassword.Length < generalConfiguration.MinimumPasswordLength) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserPasswordLength) - { - AdditionalData = $"Required password length: {generalConfiguration.MinimumPasswordLength}", - }); - cryptographySuite.SetUserPassword(dbUser, newPassword, newUser); - return null; + return await userAuthority.InvokeTransformable( + this, + authority => authority.GetId(id, true, false, cancellationToken)); } } } diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 838b7a1bcf2..3ff0f0bcac6 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -4,19 +4,16 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; 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.Configuration; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Utils; @@ -30,24 +27,24 @@ namespace Tgstation.Server.Host.Controllers public class UserGroupController : ApiController { /// - /// The for the . + /// The for the . /// - readonly GeneralConfiguration generalConfiguration; + readonly IRestAuthorityInvoker userGroupAuthority; /// /// Initializes a new instance of the class. /// /// The for the . /// The for the . - /// The containing the value of . - /// The for the . /// The for the . + /// The for the . + /// The value of . public UserGroupController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - IOptions generalConfigurationOptions, + IApiHeadersProvider apiHeaders, ILogger logger, - IApiHeadersProvider apiHeaders) + IRestAuthorityInvoker userGroupAuthority) : base( databaseContext, authenticationContext, @@ -55,9 +52,23 @@ public UserGroupController( logger, true) { - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.userGroupAuthority = userGroupAuthority ?? throw new ArgumentNullException(nameof(userGroupAuthority)); } + /// + /// Transform a into a . + /// + /// The to transform. + /// The transformed . + static Models.PermissionSet? TransformApiPermissionSet(Api.Models.PermissionSet? permissionSet) + => permissionSet != null + ? new Models.PermissionSet + { + InstanceManagerRights = permissionSet?.InstanceManagerRights, + AdministrationRights = permissionSet?.AdministrationRights, + } + : null; + /// /// Create a new . /// @@ -75,30 +86,12 @@ public async ValueTask Create([FromBody] UserGroupCreateRequest m if (model.Name == null) return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - var totalGroups = await DatabaseContext - .Groups - .AsQueryable() - .CountAsync(cancellationToken); - if (totalGroups >= generalConfiguration.UserGroupLimit) - return Conflict(new ErrorMessageResponse(ErrorCode.UserGroupLimitReached)); - - var permissionSet = new Models.PermissionSet - { - AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - var dbGroup = new UserGroup - { - Name = model.Name, - PermissionSet = permissionSet, - }; - - DatabaseContext.Groups.Add(dbGroup); - await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); - - return Created(dbGroup.ToApi(true)); + return await userGroupAuthority.InvokeTransformable( + this, + authority => authority.Create( + model.Name, + TransformApiPermissionSet(model.PermissionSet), + cancellationToken)); } /// @@ -112,38 +105,17 @@ public async ValueTask Create([FromBody] UserGroupCreateRequest m [HttpPost] [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(UserGroupResponse), 200)] - public async ValueTask Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken) + public ValueTask Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(model); - var currentGroup = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Id) - .Include(x => x.PermissionSet) - .Include(x => x.Users) - .FirstOrDefaultAsync(cancellationToken); - - if (currentGroup == default) - return this.Gone(); - - if (model.PermissionSet != null) - { - currentGroup.PermissionSet!.AdministrationRights = model.PermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; - currentGroup.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; - } - - currentGroup.Name = model.Name ?? currentGroup.Name; - - await DatabaseContext.Save(cancellationToken); - - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ReadUsers)) - return Json(new UserGroupResponse - { - Id = currentGroup.Id, - }); - - return Json(currentGroup.ToApi(true)); + return userGroupAuthority.InvokeTransformable( + this, + authority => authority.Update( + model.Require(x => x.Id), + model.Name, + TransformApiPermissionSet(model.PermissionSet), + cancellationToken)); } /// @@ -155,23 +127,11 @@ public async ValueTask Update([FromBody] UserGroupUpdateRequest m /// Retrieve successfully. /// The requested does not currently exist. [HttpGet("{id}")] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserGroupAuthority.GetId))] [ProducesResponseType(typeof(UserGroupResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] - public async ValueTask GetId(long id, CancellationToken cancellationToken) - { - // this functions as userId - var group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.Users) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - if (group == default) - return this.Gone(); - return Json(group.ToApi(true)); - } + public ValueTask GetId(long id, CancellationToken cancellationToken) + => userGroupAuthority.InvokeTransformable(this, authority => authority.GetId(id, true, cancellationToken)); /// /// Lists all s. @@ -182,17 +142,14 @@ public async ValueTask GetId(long id, CancellationToken cancellat /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserGroupAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( () => ValueTask.FromResult( new PaginatableResult( - DatabaseContext - .Groups - .AsQueryable() - .Include(x => x.Users) - .Include(x => x.PermissionSet) + userGroupAuthority + .InvokeQueryable(authority => authority.Queryable(true)) .OrderBy(x => x.Id))), null, page, @@ -213,27 +170,9 @@ public ValueTask List([FromQuery] int? page, [FromQuery] int? pag [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 409)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] - public async ValueTask Delete(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 NoContent(); - - // find out how we failed - var groupExists = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .AnyAsync(cancellationToken); - - return groupExists - ? Conflict(new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)) - : this.Gone(); - } + public ValueTask Delete(long id, CancellationToken cancellationToken) +#pragma warning disable API1001 // The response type is RIGHT THERE ^^^ + => userGroupAuthority.Invoke(this, authority => authority.DeleteEmpty(id, cancellationToken)); +#pragma warning restore API1001 } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0730eabd060..fc201f72cca 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -8,6 +8,7 @@ using Elastic.CommonSchema.Serilog; +using HotChocolate.AspNetCore; using HotChocolate.Types; using Microsoft.AspNetCore.Authentication; @@ -37,6 +38,8 @@ 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; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment.Remote; @@ -53,6 +56,9 @@ using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.GraphQL.Types.Interceptors; using Tgstation.Server.Host.GraphQL.Types.Scalars; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -290,11 +296,49 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett // configure graphql if (postSetupServices.InternalConfiguration.EnableGraphQL) services + .AddScoped() .AddGraphQLServer() .AddAuthorization() + .ModifyOptions(options => + { + options.EnsureAllNodesCanBeResolved = true; + options.EnableFlagEnums = true; + }) +#if DEBUG + .ModifyCostOptions(options => + { + options.EnforceCostLimits = false; + }) +#endif + .AddMutationConventions() + .AddInMemorySubscriptions() + .AddGlobalObjectIdentification() + .AddQueryFieldToMutationPayloads() + .ModifyOptions(options => + { + options.EnableDefer = true; + }) + .ModifyPagingOptions(pagingOptions => + { + pagingOptions.IncludeTotalCount = true; + pagingOptions.RequirePagingBoundaries = false; + pagingOptions.DefaultPageSize = ApiController.DefaultPageSize; + pagingOptions.MaxPageSize = ApiController.MaximumPageSize; + }) + .AddFiltering() + .AddSorting() + .AddHostTypes() + .AddErrorFilter() + .AddType() + .AddType() + .AddType() + .AddType() .AddType() .BindRuntimeType() - .AddQueryType(); + .TryAddTypeInterceptor() + .AddQueryType() + .AddMutationType() + .AddSubscriptionType(); void AddTypedContext() where TContext : DatabaseContext @@ -343,6 +387,7 @@ void AddTypedContext() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, PasswordHasher>(); // configure platform specific services @@ -431,6 +476,14 @@ void AddTypedContext() services.AddSingleton(); services.AddSingleton(); + // configure authorities + services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(RestAuthorityInvoker<>)); + services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(GraphQLAuthorityInvoker<>)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // configure misc services services.AddSingleton(); services.AddSingleton(); @@ -616,7 +669,16 @@ public void Configure( if (internalConfiguration.EnableGraphQL) { logger.LogWarning("Enabling GraphQL. This API is experimental and breaking changes may occur at any time!"); - endpoints.MapGraphQL(Routes.GraphQL); + var gqlOptions = new GraphQLServerOptions + { + EnableBatching = true, + }; + + gqlOptions.Tool.Enable = generalConfiguration.HostApiDocumentation; + + endpoints + .MapGraphQL(Routes.GraphQL) + .WithOptions(gqlOptions); } }); @@ -643,7 +705,7 @@ void ConfigureAuthenticationPipeline(IServiceCollection services) services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); // what if you // wanted to just do this: @@ -684,6 +746,15 @@ void ConfigureAuthenticationPipeline(IServiceCollection services) return Task.CompletedTask; }, + OnTokenValidated = context => context + .HttpContext + .RequestServices + .GetRequiredService() + .ValidateToken( + context, + context + .HttpContext + .RequestAborted), }; }); } diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index d79bb709f93..3d4bd73f0f8 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -263,7 +263,7 @@ public static Action GetConfigur ConfigureMethodName, BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException($"Context type {typeof(TDatabaseContext).FullName} missing static {ConfigureMethodName} function!"); - return (optionsBuilder, config) => configureFunction.Invoke(null, new object[] { optionsBuilder, config }); + return (optionsBuilder, config) => configureFunction.Invoke(null, [optionsBuilder, config]); } /// diff --git a/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs b/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs index 818b0c44b59..dad19dd6f1e 100644 --- a/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs @@ -19,6 +19,21 @@ namespace Tgstation.Server.Host.Extensions /// static class ControllerBaseExtensions { + /// + /// Generic 201 response with a given . + /// + /// The the request is coming from. + /// The accompanying API payload. + /// A with the given . + public static ObjectResult Created(this ControllerBase controller, object payload) => controller.StatusCode(HttpStatusCode.Created, payload); + + /// + /// Generic 401 response. + /// + /// The the request is coming from. + /// An with . + public static ObjectResult Unauthorized(this ControllerBase controller) => controller.StatusCode(HttpStatusCode.Unauthorized, null); + /// /// Generic 410 response. /// diff --git a/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs b/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs index 55ead2144a5..8d2b6b60249 100644 --- a/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs @@ -60,7 +60,7 @@ static class TopicClientExtensions logger.LogTrace("End topic request #{requestId}", localRequestId); return byondResponse; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException && ex is not ArgumentException) { logger.LogWarning(ex, "SendTopic exception!{retryDetails}", priority ? $" {i} attempts remaining." : String.Empty); diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs new file mode 100644 index 00000000000..38315ca79e3 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs @@ -0,0 +1,46 @@ +using System; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; + +#pragma warning disable CA1032 // Shitty unneeded additional Exception constructors + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// representing s. + /// + public sealed class ErrorMessageException : Exception + { + /// + /// The . + /// + public ErrorCode? ErrorCode { get; } + + /// + /// The . + /// + public string? AdditionalData { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// Fallback . + public ErrorMessageException(ErrorMessageResponse errorMessage, string fallbackMessage) + : base((errorMessage ?? throw new ArgumentNullException(nameof(errorMessage))).Message ?? fallbackMessage) + { + ErrorCode = errorMessage.ErrorCode != default ? errorMessage.ErrorCode : null; + AdditionalData = errorMessage.AdditionalData; + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public ErrorMessageException(ErrorCode errorCode) + : this(new ErrorMessageResponse(errorCode), String.Empty) + { + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs new file mode 100644 index 00000000000..cbb9ac05e8c --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs @@ -0,0 +1,79 @@ +using System; + +using HotChocolate; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// for transforming -like . + /// + sealed class ErrorMessageFilter : IErrorFilter + { + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public ErrorMessageFilter(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IError OnError(IError error) + { + ArgumentNullException.ThrowIfNull(error); + + if (error.Exception == null) + return error; + + var errorBuilder = ErrorBuilder.FromError(error) + .RemoveException() + .ClearExtensions(); + + const string ErrorCodeFieldName = "errorCode"; + const string AdditionalDataFieldName = "additionalData"; + + if (error.Exception is DbUpdateException dbUpdateException) + { + if (dbUpdateException.InnerException is OperationCanceledException) + { + logger.LogTrace(dbUpdateException, "Rethrowing DbUpdateException as OperationCanceledException"); + throw dbUpdateException.InnerException; + } + + logger.LogDebug(dbUpdateException, "Database conflict!"); + return errorBuilder + .SetMessage(dbUpdateException.Message) + .SetExtension(ErrorCodeFieldName, ErrorCode.DatabaseIntegrityConflict) + .SetExtension(AdditionalDataFieldName, (dbUpdateException.InnerException ?? dbUpdateException).Message) + .Build(); + } + + if (error.Exception is not ErrorMessageException errorMessageException) + { + return errorBuilder + .SetMessage(error.Exception.Message) + .SetExtension(ErrorCodeFieldName, ErrorCode.InternalServerError) + .SetExtension(AdditionalDataFieldName, error.Exception.ToString()) + .Build(); + } + + return errorBuilder + .SetMessage(errorMessageException.Message) + .SetExtension(ErrorCodeFieldName, errorMessageException.ErrorCode) + .SetExtension(AdditionalDataFieldName, errorMessageException.AdditionalData) + .Build(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs new file mode 100644 index 00000000000..1de582c06f7 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -0,0 +1,24 @@ +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Types; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// Management interface for the parent . + /// + public interface IGateway + { + /// + /// Gets . + /// + /// The for the . + GatewayInformation Information(); + + /// + /// Queries all s in the . + /// + /// Queryable s. + IQueryable Instances(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs new file mode 100644 index 00000000000..63ca2b3b31c --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs @@ -0,0 +1,21 @@ +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// Represents a tgstation-server installation. + /// + public interface IServerNode + { + /// + /// Access the for the . + /// + /// The of . + /// The for the . + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs new file mode 100644 index 00000000000..f2a57702cc2 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs @@ -0,0 +1,21 @@ +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// A lightly scoped . + /// + public interface IUserName + { + /// + /// The ID of the user. + /// + [ID] + public long Id { get; } + + /// + /// The name of the user. + /// + public string Name { get; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 7988affc6ba..4b68aa9b89c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -1,11 +1,36 @@ -namespace Tgstation.Server.Host.GraphQL +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; + +namespace Tgstation.Server.Host.GraphQL { /// /// Root type for GraphQL mutations. /// + /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. public sealed class Mutation { - // Intentionally left blank, use type extensions to properly scope operations to domains - // https://chillicream.com/docs/hotchocolate/v13/defining-a-schema/extending-types + /// + /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. + /// + /// The for the . + /// The for the operation. + /// A Bearer token to be used with further communication with the server. + [Error(typeof(ErrorMessageException))] + public ValueTask Login( + [Service] IGraphQLAuthorityInvoker loginAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(loginAuthority); + + return loginAuthority.Invoke( + authority => authority.AttemptLogin(cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs new file mode 100644 index 00000000000..8d5cd5e6831 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs @@ -0,0 +1,32 @@ +using HotChocolate; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.GraphQL.Types.Scalars; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Success response for a login attempt. + /// + public sealed class LoginPayload : ILegacyApiTransformable + { + /// + /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time. + /// + [GraphQLType] + public required string Bearer { get; init; } + + /// + /// The that was logged in. + /// + public required Types.User User { get; init; } + + /// + [GraphQLIgnore] + public TokenResponse ToApi() + => new() + { + Bearer = Bearer, + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs new file mode 100644 index 00000000000..9e0ffc27a1f --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs @@ -0,0 +1,20 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Updates a set of permissions for the server. values default to their "None" variants. + /// + public sealed class PermissionSetInput + { + /// + /// The for the . + /// + public required AdministrationRights? AdministrationRights { get; init; } + + /// + /// The for the . + /// + public required InstanceManagerRights? InstanceManagerRights { get; init; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs new file mode 100644 index 00000000000..92ef77d288c --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +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 +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + public sealed class UserGroupMutations + { + /// + /// Transform a into a . + /// + /// The to transform. + /// The transformed . + static Models.PermissionSet? TransformApiPermissionSet(PermissionSetInput? permissionSet) + => permissionSet != null + ? new Models.PermissionSet + { + InstanceManagerRights = permissionSet?.InstanceManagerRights, + AdministrationRights = permissionSet?.AdministrationRights, + } + : null; + + /// + /// Creates a . + /// + /// The of the . + /// The initial permission set for the . + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserGroup( + string name, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(userGroupAuthority); + + return userGroupAuthority.InvokeTransformable( + authority => authority.Create(name, TransformApiPermissionSet(permissionSet), cancellationToken)); + } + + /// + /// Updates a . + /// + /// The of the to update. + /// Optional new for the . + /// Optional new permission set for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Update))] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserGroup( + [ID(nameof(UserGroup))] long id, + string? newName, + PermissionSetInput? newPermissionSet, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformable( + authority => authority.Update(id, newName, TransformApiPermissionSet(newPermissionSet), cancellationToken)); + } + + /// + /// Deletes a . + /// + /// The of the to update. + /// 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, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + await userGroupAuthority.Invoke( + authority => authority.DeleteEmpty(id, cancellationToken)); + + return new Query(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs new file mode 100644 index 00000000000..64c671a7cbe --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -0,0 +1,543 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +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; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + public sealed class UserMutations + { + /// + /// Creates a TGS user specifying a personal . + /// + /// The of the . + /// The password of the . + /// If the is . + /// The s for the user. + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndPermissionSet( + string name, + string password, + bool? enabled, + IEnumerable? oAuthConnections, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = password, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a TGS user specifying the they will belong to. + /// + /// The of the . + /// The password of the . + /// If the is . + /// The s for the user. + /// The of the the will belong to. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndGroup( + string name, + string password, + bool? enabled, + IEnumerable? oAuthConnections, + [ID(nameof(UserGroup))] long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = password, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a TGS user authenticated with one or mor s specifying a personal . + /// + /// The of the . + /// The s for the user. + /// If the is . + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByOAuthAndPermissionSet( + string name, + IEnumerable oAuthConnections, + bool? enabled, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(oAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = String.Empty, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + true, + cancellationToken)); + } + + /// + /// Creates a TGS user specifying the they will belong to. + /// + /// The of the . + /// The s for the user. + /// The of the the will belong to. + /// If the is . + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByOAuthAndGroup( + string name, + IEnumerable oAuthConnections, + [ID(nameof(UserGroup))] long groupId, + bool? enabled, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(oAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = String.Empty, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + true, + cancellationToken)); + } + + /// + /// Creates a system user specifying a personal . + /// + /// The of the . + /// If the is . + /// The s for the user. + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndPermissionSet( + string systemIdentifier, + bool? enabled, + IEnumerable? oAuthConnections, + PermissionSetInput permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(systemIdentifier); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + SystemIdentifier = systemIdentifier, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a system user specifying the they will belong to. + /// + /// The of the . + /// If the is . + /// The of the the will belong to. + /// The s for the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndGroup( + string systemIdentifier, + bool? enabled, + [ID(nameof(UserGroup))] long groupId, + IEnumerable? oAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(systemIdentifier); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + SystemIdentifier = systemIdentifier, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Sets the current user's password. + /// + /// The new password for the current user. + /// The to get the of the user. + /// The for the . + /// The for the operation. + /// The updated current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] + [Error(typeof(ErrorMessageException))] + public ValueTask SetCurrentUserPassword( + string newPassword, + [Service] IAuthenticationContext authenticationContext, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(newPassword); + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = authenticationContext.User.Id, + Password = newPassword, + }, + cancellationToken)); + } + + /// + /// Sets the current user's s. + /// + /// The new s for the current user. + /// The to get the of the user. + /// The for the . + /// The for the operation. + /// The updated current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnOAuthConnections)] + [Error(typeof(ErrorMessageException))] + public ValueTask SetCurrentOAuthConnections( + IEnumerable newOAuthConnections, + [Service] IAuthenticationContext authenticationContext, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(newOAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = authenticationContext.User.Id, + OAuthConnections = newOAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); + } + + /// + /// Updates a s properties. + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Optional new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUser( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + null, + null, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a , setting new values for its owned . + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Updated owned for the user. Note that setting this on a in a will remove them from that group. + /// The new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserSetOwnedPermissionSet( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + PermissionSetInput newPermissionSet, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + newPermissionSet, + null, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a , setting new values for its owned . + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// of the to move the to. + /// The new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserSetGroup( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + [ID(nameof(UserGroup))] long newGroupId, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + null, + newGroupId, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a user. + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Optional updated new owned for the user. Note that setting this on a in a will remove them from that group. + /// Optional of the to move the to. + /// Optional new s for the . + /// The for the . + /// The for the operation. + /// The updated . + ValueTask UpdateUserCore( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + PermissionSetInput? newPermissionSet, + long? newGroupId, + IEnumerable? newOAuthConnections, + IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + => userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = id, + Name = casingOnlyNameChange, + Password = newPassword, + Enabled = enabled, + PermissionSet = newPermissionSet != null + ? new Api.Models.PermissionSet + { + InstanceManagerRights = newPermissionSet.InstanceManagerRights, + AdministrationRights = newPermissionSet.AdministrationRights, + } + : null, + Group = newGroupId.HasValue + ? new Api.Models.Internal.UserGroup + { + Id = newGroupId.Value, + } + : null, + OAuthConnections = newOAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs index 3b57958392b..ca9fed764f6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Query.cs +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -1,6 +1,4 @@ -#pragma warning disable CA1724 - -using Tgstation.Server.Host.GraphQL.Types; +#pragma warning disable CA1724 // Dumb conflict with Microsoft.EntityFrameworkCore.Query namespace Tgstation.Server.Host.GraphQL { @@ -10,9 +8,9 @@ namespace Tgstation.Server.Host.GraphQL public sealed class Query { /// - /// Gets the . + /// Gets the . /// - /// A new . - public ServerSwarm Swarm() => new(); + /// A new . + public Types.ServerSwarm Swarm() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs new file mode 100644 index 00000000000..114a7622ada --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Subscriptions; +using HotChocolate.Types; + +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// Root type for GraphQL subscriptions. + /// + /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + public sealed class Subscription + { + /// + /// Gets the topic name for the login session represented by a given . + /// + /// The to generate the topic for. + /// The topic for the given . + public static string SessionInvalidatedTopic(IAuthenticationContext authenticationContext) + { + ArgumentNullException.ThrowIfNull(authenticationContext); + return $"SessionInvalidated.{authenticationContext.SessionId}"; + } + + /// + /// for . + /// + /// The . + /// The . + /// The for the request. + /// The for the operation. + /// A resulting in a of the for the . + public ValueTask> SessionInvalidatedStream( + [Service] HotChocolate.Subscriptions.ITopicEventReceiver receiver, // Intentionally not using our override here, topic callers are built to explicitly handle cases of server shutdown + [Service] ISessionInvalidationTracker invalidationTracker, + [Service] IAuthenticationContext authenticationContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(invalidationTracker); + + var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext), cancellationToken); + invalidationTracker.TrackSession(authenticationContext); + return subscription; + } + + /// + /// Receive a immediately before the current login session is invalidated. + /// + /// 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/ITopicEventReceiver.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs new file mode 100644 index 00000000000..ec843558cb1 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs @@ -0,0 +1,9 @@ +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Implementation of that works around the issue described in https://github.com/ChilliCream/graphql-platform/issues/6698. + /// + public interface ITopicEventReceiver : HotChocolate.Subscriptions.ITopicEventReceiver + { + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs new file mode 100644 index 00000000000..37e98678138 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Reasons TGS may invalidate a user's login session. + /// + public enum SessionInvalidationReason + { + /// + /// The callers JWT expired. + /// + TokenExpired, + + /// + /// An update to the caller's identity requiring reauthentication was made. + /// + UserUpdated, + + /// + /// TGS is shutting down or restarting. Note, depending on server configuration, the current session may not actually be invalid upon restarting. However, the information required to determine this is not exposed to clients. + /// + ServerShutdown, + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs new file mode 100644 index 00000000000..1fec5ee14e9 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate.Execution; +using HotChocolate.Subscriptions; + +using Microsoft.Extensions.Hosting; + +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + sealed class ShutdownAwareTopicEventReceiver : ITopicEventReceiver, IAsyncDisposable + { + /// + /// The for the . + /// + readonly IHostApplicationLifetime hostApplicationLifetime; + + /// + /// The wrapped . + /// + readonly HotChocolate.Subscriptions.ITopicEventReceiver hotChocolateReceiver; + + /// + /// A of s that were created for this scope. + /// + readonly ConcurrentBag registrations; + + /// + /// A of s returned from initiating calls on s. + /// + readonly ConcurrentBag disposeTasks; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public ShutdownAwareTopicEventReceiver( + IHostApplicationLifetime hostApplicationLifetime, + HotChocolate.Subscriptions.ITopicEventReceiver hotChocolateReceiver) + { + this.hostApplicationLifetime = hostApplicationLifetime ?? throw new ArgumentNullException(nameof(hostApplicationLifetime)); + this.hotChocolateReceiver = hotChocolateReceiver ?? throw new ArgumentNullException(nameof(hotChocolateReceiver)); + + registrations = new ConcurrentBag(); + disposeTasks = new ConcurrentBag(); + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var registration in registrations) + { + registration.Dispose(); + } + + await Task.WhenAll(disposeTasks); + } + + /// + public ValueTask> SubscribeAsync(string topicName, CancellationToken cancellationToken) + => WrapWithApplicationLifetimeCancellation( + hotChocolateReceiver.SubscribeAsync(topicName, cancellationToken)); + + /// + public ValueTask> SubscribeAsync(string topicName, int? bufferCapacity, TopicBufferFullMode? bufferFullMode, CancellationToken cancellationToken) + => WrapWithApplicationLifetimeCancellation( + hotChocolateReceiver.SubscribeAsync(topicName, bufferCapacity, bufferFullMode, cancellationToken)); + + /// + /// Wraps a given with cancellation awareness. + /// + /// The of message. + /// The result of a call to the . + /// The result of with lifetime aware cancellation. + async ValueTask> WrapWithApplicationLifetimeCancellation(ValueTask> sourceStreamTask) + { + var sourceStream = await sourceStreamTask; + registrations.Add( + hostApplicationLifetime.ApplicationStopping.Register( + () => disposeTasks.Add( + sourceStream.DisposeAsync().AsTask()))); + return sourceStream; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs new file mode 100644 index 00000000000..2369df8ab4a --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +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; + +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Subscriptions for . + /// + [ExtendObjectType(typeof(Subscription))] + public sealed class UserSubscriptions + { + /// + /// The name of the topic for when any user is updated. + /// + const string UserUpdatedTopic = "UserUpdated"; + + /// + /// Get the names of the topics to send to when a is updated. + /// + /// The of the updated . + /// An of topic s. + public static IEnumerable UserUpdatedTopics(long userId) + { + yield return UserUpdatedTopic; + yield return SpecificUserUpdatedTopic(userId); + } + + /// + /// The name of the topic for when a specific is updated. + /// + /// The of the updated . + /// The topic . + static string SpecificUserUpdatedTopic(long userId) + => $"{UserUpdatedTopic}.{userId}"; + + /// + /// for . + /// + /// The optional of the to scope updates to. + /// The . + /// The for the operation. + /// A resulting in the requested updates. + public ValueTask> UserUpdatedStream( + [ID(nameof(User))] long? userId, + [Service] ITopicEventReceiver receiver, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + var topic = userId.HasValue ? SpecificUserUpdatedTopic(userId.Value) : UserUpdatedTopic; + return receiver.SubscribeAsync(topic, cancellationToken); + } + + /// + /// Receive an update for all changes. + /// + /// The received from the publisher. + /// The updated . + [Subscribe(With = nameof(UserUpdatedStream))] + [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] + public User UserUpdated([EventMessage] User user) + { + ArgumentNullException.ThrowIfNull(user); + return user; + } + + /// + /// for . + /// + /// The . + /// The for the request. + /// The for the operation. + /// A resulting in updates for the current . + public ValueTask> CurrentUserUpdatedStream( + [Service] ITopicEventReceiver receiver, + [Service] IAuthenticationContext authenticationContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(authenticationContext); + return receiver.SubscribeAsync(SpecificUserUpdatedTopic(Models.ModelExtensions.Require(authenticationContext.User, user => user.Id)), cancellationToken); + } + + /// + /// Receive an update to the logged in when it is changed. + /// + /// The received from the publisher. + /// The updated . + [Subscribe(With = nameof(CurrentUserUpdatedStream))] + [TgsGraphQLAuthorize] + public User CurrentUserUpdated([EventMessage] User user) + { + ArgumentNullException.ThrowIfNull(user); + + return user; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs index 20c8a08b80a..4f45c223061 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs @@ -1,4 +1,8 @@ -namespace Tgstation.Server.Host.GraphQL.Types +using System.Diagnostics.CodeAnalysis; + +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a database entity. @@ -8,12 +12,21 @@ public abstract class Entity /// /// The ID of the . /// - public long Id { get; } + [ID] + public required long Id { get; init; } + + /// + /// Initializes a new instance of the class. + /// + protected Entity() + { + } /// /// Initializes a new instance of the class. /// /// The value of . + [SetsRequiredMembers] protected Entity(long id) { Id = id; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs new file mode 100644 index 00000000000..aefc2562bb6 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; + +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Properties; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents information about a retrieved via a . + /// + public sealed class GatewayInformation + { + /// + /// Gets the minimum valid password length for TGS users. + /// + /// The containing the . + /// A specifying the minimumn valid password length for TGS users. + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] + public uint? MinimumPasswordLength( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.MinimumPasswordLength; + } + + /// + /// Gets the maximum allowed attached instances for the . + /// + /// The containing the . + /// A specifying the maximum allowed attached instances for the . + [TgsGraphQLAuthorize(InstanceManagerRights.Create)] + public uint? InstanceLimit( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.InstanceLimit; + } + + /// + /// Gets the maximum allowed registered s for the . + /// + /// 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( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.UserLimit; + } + + /// + /// Gets the maximum allowed registered s for the . + /// + /// 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( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.UserGroupLimit; + } + + /// + /// Gets the locations s may be created or attached from if there are restrictions. + /// + /// 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( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.ValidInstancePaths; + } + + /// + /// Gets a flag indicating whether or not the current runs on a Windows operating system. + /// + /// The to use. + /// if the runs on a Windows operating system, otherwise. + [TgsGraphQLAuthorize] + 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); + + /// + /// Gets the of tgstation-server the is running. + /// + /// The to use. + /// The of tgstation-server the is running. + [TgsGraphQLAuthorize] + public Version? Version( + [Service] IAssemblyInformationProvider assemblyInformationProvider) + { + ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + return assemblyInformationProvider.Version; + } + + /// + /// Gets the major HTTP API number of the . + /// + public int MajorApiVersion => ApiHeaders.Version.Major; + + /// + /// Gets the HTTP API of the . + /// + [TgsGraphQLAuthorize] + public Version? ApiVersion => ApiHeaders.Version; + + /// + /// Gets the DMAPI interop the uses. + /// + [TgsGraphQLAuthorize] + public Version? DMApiVersion => DMApiConstants.InteropVersion; + + /// + /// Gets the information needed to perform open authentication with the . + /// + /// The to use. + /// A map of enabled s to their . + public IReadOnlyDictionary OAuthProviderInfos( + [Service] IOAuthProviders oAuthProviders) + { + ArgumentNullException.ThrowIfNull(oAuthProviders); + return oAuthProviders.ProviderInfos(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs new file mode 100644 index 00000000000..192a5b377b1 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -0,0 +1,18 @@ +using System; +using System.Linq; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a game server instance. + /// + public sealed class Instance : Entity + { + /// + /// Queries all s in the . + /// + /// Queryable s. + public IQueryable QueryableInstancePermissionSets() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs new file mode 100644 index 00000000000..76bcabcac68 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -0,0 +1,45 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a set of permissions for an . + /// + public sealed class InstancePermissionSet + { + /// + /// The of the . + /// + public InstancePermissionSetRights? InstancePermissionSetRights { get; set; } + + /// + /// The of the . + /// + public EngineRights? EngineRights { get; set; } + + /// + /// The of the . + /// + public DreamDaemonRights? DreamDaemonRights { get; set; } + + /// + /// The of the . + /// + public DreamMakerRights? DreamMakerRights { get; set; } + + /// + /// The of the . + /// + public RepositoryRights? RepositoryRights { get; set; } + + /// + /// The of the . + /// + public ChatBotRights? ChatBotRights { get; set; } + + /// + /// The of the . + /// + public ConfigurationRights? ConfigurationRights { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs new file mode 100644 index 00000000000..538c32b3ad3 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +using HotChocolate.Configuration; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Definitions; + +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Types.Interceptors +{ + /// + /// Fixes the names used for the default flags types in API rights. + /// + sealed class RightsTypeInterceptor : TypeInterceptor + { + /// + /// Prefix normally used by hot chocolate for flag enums. + /// + const string IsPrefix = "is"; + + /// + /// Name given to default None fields. + /// + const string NoneFieldName = $"{IsPrefix}None"; + + /// + /// Names of rights GraphQL object types. + /// + private readonly HashSet objectNames; + + /// + /// Names of rights GraphQL input types. + /// + private readonly HashSet inputNames; + + /// + /// Initializes a new instance of the class. + /// + public RightsTypeInterceptor() + { + var rightTypes = Enum.GetValues(); + objectNames = new HashSet(rightTypes.Length); + inputNames = new HashSet(rightTypes.Length); + + foreach (var rightType in rightTypes) + { + var rightName = rightType.ToString(); + var flagName = $"{rightName}RightsFlags"; + + objectNames.Add(flagName); + inputNames.Add($"{flagName}Input"); + } + } + + /// + /// Fix the "is" prefix on a given set of . + /// + /// The of to correct. + /// The of s to operate on. + static void FixFields(IBindableList fields) + where TField : FieldDefinitionBase + { + TField? noneField = null; + + foreach (var field in fields) + { + var fieldName = field.Name; + if (fieldName == NoneFieldName) + { + noneField = field; + continue; + } + + if (!fieldName.StartsWith(IsPrefix)) + throw new InvalidOperationException("Expected flags enum type field to start with \"is\"!"); + + field.Name = $"can{fieldName[IsPrefix.Length..]}"; + } + + if (noneField == null) + throw new InvalidOperationException($"Expected flags enum type field to contain \"{NoneFieldName}\"!"); + + fields.Remove(noneField); + } + + /// + /// Fix the for a tweaked field. + /// + /// The to fix. + static void FixFormatter(IInputValueFormatter inputValueFormatter) + { + // now we're hacking privates, but there's a dictionary with bad keys here that needs adjusting + var dictionary = (Dictionary)(inputValueFormatter + .GetType() + .GetField("_flags", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(inputValueFormatter) + ?? throw new InvalidOperationException("Could not locate private enum mapping dictionary field!")); + + foreach (var key in dictionary.Keys.ToList()) + { + if (key == NoneFieldName) + { + dictionary.Remove(key); + continue; + } + + var value = dictionary[key]; + var newKey = $"can{key.Substring(IsPrefix.Length)}"; + dictionary.Remove(key); + dictionary.Add(newKey, value); + } + } + + /// + public override void OnAfterRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) + { + ArgumentNullException.ThrowIfNull(definition); + + if (definition is ObjectTypeDefinition objectTypeDef) + { + if (objectNames.Contains(objectTypeDef.Name)) + FixFields(objectTypeDef.Fields); + } + else if (definition is InputObjectTypeDefinition inputTypeDef) + { + const string PermissionSetInputName = $"{nameof(PermissionSet)}Input"; + const string InstancePermissionSetInputName = $"{nameof(InstancePermissionSet)}Input"; + + var name = inputTypeDef.Name; + if (inputNames.Contains(name)) + FixFields(inputTypeDef.Fields); + else if (name == PermissionSetInputName || name == InstancePermissionSetInputName) + foreach (var field in inputTypeDef.Fields) + FixFormatter(field.Formatters.Single()); + } + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs new file mode 100644 index 00000000000..8b6c329d941 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -0,0 +1,20 @@ +using System; +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// for the this query is executing on. + /// + public sealed class LocalGateway : IGateway + { + /// + public GatewayInformation Information() => new(); + + /// + public IQueryable Instances() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs deleted file mode 100644 index fab7784385f..00000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -using HotChocolate; -using HotChocolate.Authorization; - -using Microsoft.Extensions.Options; -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.Security.OAuth; -using Tgstation.Server.Host.System; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// Represents the local tgstation-server. - /// - public sealed class LocalServer - { - /// - /// Gets . - /// - /// The to use. - /// The to use. - /// The containing the to use. - /// A new . - [AllowAnonymous] - public LocalServerInformation Information( - [Service] IOAuthProviders oAuthProviders, - [Service] IPlatformIdentifier platformIdentifier, - [Service] IOptionsSnapshot generalConfigurationOptions) - { - ArgumentNullException.ThrowIfNull(oAuthProviders); - ArgumentNullException.ThrowIfNull(platformIdentifier); - ArgumentNullException.ThrowIfNull(generalConfigurationOptions); - - var generalConfiguration = generalConfigurationOptions.Value; - return new LocalServerInformation - { - MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, - InstanceLimit = generalConfiguration.InstanceLimit, - UserLimit = generalConfiguration.UserLimit, - UserGroupLimit = generalConfiguration.UserGroupLimit, - ValidInstancePaths = generalConfiguration.ValidInstancePaths, - WindowsHost = platformIdentifier.IsWindows, - OAuthProviderInfos = oAuthProviders.ProviderInfos(), - }; - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs index e48c18ce14d..d0a36221cc1 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs @@ -1,4 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; + +using Tgstation.Server.Host.GraphQL.Interfaces; namespace Tgstation.Server.Host.GraphQL.Types { @@ -10,13 +13,32 @@ public abstract class NamedEntity : Entity /// /// The name of the . /// - public string Name { get; } + public required string Name { get; init; } + + /// + /// Initializes a new instance of the class. + /// + protected NamedEntity() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + [SetsRequiredMembers] + protected NamedEntity(NamedEntity copy) + : base(copy?.Id ?? throw new ArgumentNullException(nameof(copy))) + { + Name = copy.Name; + } /// /// Initializes a new instance of the class. /// /// The ID for the . /// The value of . + [SetsRequiredMembers] protected NamedEntity(long id, string name) : base(id) { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index 6d7b0d6b5f3..684beafdb85 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -5,29 +5,16 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a set of permissions for the server. /// - public sealed class PermissionSet : Entity + public sealed class PermissionSet { /// /// The for the . /// - public AdministrationRights AdministrationRights { get; } + public required AdministrationRights AdministrationRights { get; init; } /// /// The for the . /// - public InstanceManagerRights InstanceManagerRights { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The value of . - /// The value of . - public PermissionSet(long id, AdministrationRights administrationRights, InstanceManagerRights instanceManagerRights) - : base(id) - { - AdministrationRights = administrationRights; - InstanceManagerRights = instanceManagerRights; - } + public required InstanceManagerRights InstanceManagerRights { get; init; } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs new file mode 100644 index 00000000000..fcb9f088f45 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -0,0 +1,20 @@ +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/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs new file mode 100644 index 00000000000..47f10ccde3c --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs @@ -0,0 +1,38 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Types.Scalars +{ + /// + /// A for encoded JSON Web Tokens. + /// + public sealed class JwtType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + public JwtType() + : base("Jwt") + { + Description = "Represents an encoded JSON Web Token"; + SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519"); + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + protected override string ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return valueSyntax.Value; + } + + /// + protected override StringValueNode ParseValue(string runtimeValue) + => new(runtimeValue); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs index 76fbb55eedc..dce31b47514 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs @@ -2,6 +2,7 @@ using HotChocolate.Language; using HotChocolate.Types; + using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Host.GraphQL.Types.Scalars @@ -17,7 +18,8 @@ public sealed class SemverType : ScalarType public SemverType() : base("Semver") { - Description = "Represents a version in semver format as defined by https://semver.org/spec/v2.0.0.html"; + Description = "Represents a version in semantic versioning format"; + SpecifiedBy = new Uri("https://semver.org/spec/v2.0.0.html"); } /// @@ -60,7 +62,7 @@ protected override Version ParseLiteral(StringValueNode valueSyntax) /// protected override StringValueNode ParseValue(Version runtimeValue) - => new StringValueNode(runtimeValue.Semver().ToString()); + => new(runtimeValue.Semver().ToString()); /// protected override bool IsInstanceOfType(StringValueNode valueSyntax) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index e4ddf59dd2f..286aad2adfc 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -1,11 +1,17 @@ using System; using System.Collections.Generic; +using System.Linq; using HotChocolate; -using Tgstation.Server.Api.Models.Internal; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Properties; +using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Swarm; -using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.GraphQL.Types { @@ -15,36 +21,62 @@ namespace Tgstation.Server.Host.GraphQL.Types public sealed class ServerSwarm { /// - /// Gets the for the swarm. + /// If there is a swarm update in progress. /// - /// The to use. /// The to use. - /// A new . - public SwarmMetadata Metadata( - [Service] IAssemblyInformationProvider assemblyInformationProvider, + /// if there is an update in progress, otherwise. + [TgsGraphQLAuthorize] + public bool UpdateInProgress( [Service] IServerControl serverControl) { - ArgumentNullException.ThrowIfNull(assemblyInformationProvider); ArgumentNullException.ThrowIfNull(serverControl); - return new SwarmMetadata(assemblyInformationProvider, serverControl.UpdateInProgress); + return serverControl.UpdateInProgress; } /// - /// Gets the local . + /// Gets the swarm protocol major version in use. + /// + [TgsGraphQLAuthorize] + public int ProtocolMajorVersion => Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major; + + /// + /// Gets the swarm's . + /// + /// A new . + [TgsGraphQLAuthorize] + public Users Users() => new(); + + /// + /// Gets the connected server. /// - /// A new . - public LocalServer LocalServer() => new(); + /// The containing the current . + /// The to use. + /// A new for the local node if it is part of a swarm, otherwise. + public IServerNode CurrentNode( + [Service] IOptionsSnapshot swarmConfigurationOptions, + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + ArgumentNullException.ThrowIfNull(swarmService); + + var ourIdentifier = swarmConfigurationOptions.Value.Identifier; + if (ourIdentifier == null) + return new StandaloneNode(); + + return (IServerNode?)SwarmNode.GetSwarmNode(ourIdentifier, swarmService) ?? new StandaloneNode(); + } /// - /// Gets the for all servers in a swarm. + /// Gets all servers in the swarm. /// /// The to use. - /// A of s if the local server is part of a swarm, otherwise. - public List? Servers( + /// A of s if the local node is part of a swarm, otherwise. + [TgsGraphQLAuthorize] + public List? Nodes( [Service] ISwarmService swarmService) { ArgumentNullException.ThrowIfNull(swarmService); - return swarmService.GetSwarmServers(); + return swarmService.GetSwarmServers()?.Select(x => new SwarmNode(x)).ToList(); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs new file mode 100644 index 00000000000..335bee3543e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs @@ -0,0 +1,19 @@ +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// A not running as part of a larger . + /// + public sealed class StandaloneNode : IServerNode + { + /// + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + => new LocalGateway(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs deleted file mode 100644 index 19fe192b238..00000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; - -using Tgstation.Server.Api; -using Tgstation.Server.Host.Components.Interop; -using Tgstation.Server.Host.System; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// Represents information that is constant across all servers in a . - /// - public sealed class SwarmMetadata - { - /// - /// The version of the host. - /// - public Version Version { get; } - - /// - /// The version of the host. - /// - public Version ApiVersion => ApiHeaders.Version; - - /// - /// The DMAPI interop version the server uses. - /// - public Version DMApiVersion => DMApiConstants.InteropVersion; - - /// - /// If there is a server update in progress. - /// - public bool UpdateInProgress { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The used to derive the . - /// The value of . - public SwarmMetadata(IAssemblyInformationProvider assemblyInformationProvider, bool updateInProgress) - { - ArgumentNullException.ThrowIfNull(assemblyInformationProvider); - Version = assemblyInformationProvider.Version; - UpdateInProgress = updateInProgress; - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs new file mode 100644 index 00000000000..520f8bd6c7c --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; + +using HotChocolate; +using HotChocolate.Types.Relay; + +using Microsoft.Extensions.Options; + +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 +{ + /// + /// Represents a node server in a swarm. + /// + [Node] + public sealed class SwarmNode : IServerNode + { + /// + /// The node ID. + /// + [ID] + public string NodeId => Identifier; + + /// + /// The swarm server ID. + /// + public string Identifier { get; } + + /// + /// The swarm server's internal . + /// + public Uri Address { get; } + + /// + /// The swarm server's optional public address. + /// + public Uri? PublicAddress { get; } + + /// + /// Whether or not the server is the swarm's controller. + /// + public bool Controller { get; } + + /// + /// Node resolver for s. + /// + /// The . + /// The to load from. + /// A new with the matching if found, otherwise. + [TgsGraphQLAuthorize] + public static SwarmNode? GetSwarmNode( + string identifier, + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(identifier); + ArgumentNullException.ThrowIfNull(swarmService); + var info = swarmService + .GetSwarmServers() + ?.FirstOrDefault(x => x.Identifier == identifier); + + if (info == null) + return null; + + return new SwarmNode(info); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use for initialization. + public SwarmNode(SwarmServerInformation? nodeInformation) + { + ArgumentNullException.ThrowIfNull(nodeInformation); + + Identifier = nodeInformation.Identifier!; + Address = nodeInformation.Address!; + PublicAddress = nodeInformation.PublicAddress; + Controller = nodeInformation.Controller; + } + + /// + /// Gets the 's . + /// + /// The containing the current . + /// A new . + /// The 's . + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + + bool local = Identifier == swarmConfigurationOptions.Value.Identifier; + if (local) + return new LocalGateway(); + + throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); + + // return new RemoteGateway(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 93badc81655..7e993fd358e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -1,84 +1,170 @@ using System; -using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using HotChocolate; +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 { /// /// A user registered in the server. /// - public sealed class User : NamedEntity + [Node] + public sealed class User : NamedEntity, IUserName { /// /// If the is enabled since users cannot be deleted. System users cannot be disabled. /// - public bool Enabled { get; } + public required bool Enabled { get; init; } + + /// + /// The user's canonical (Uppercase) name. + /// + public required string CanonicalName { get; init; } /// /// When the was created. /// - public DateTimeOffset CreatedAt { get; } + public required DateTimeOffset CreatedAt { get; init; } /// /// The SID/UID of the on Windows/POSIX respectively. /// - public string? SystemIdentifier { get; } + public required string? SystemIdentifier { get; init; } /// /// The of the . /// - readonly long createdById; + [GraphQLIgnore] + public required long? CreatedById { get; init; } + + /// + /// The of the . + /// + [GraphQLIgnore] + public required long? GroupId { get; init; } /// - /// Initializes a new instance of the class. + /// Node resolver for s. /// - /// The . - /// The . - /// The value of . - /// The value of . - /// The value of . - /// The value of . - public User( + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUser( long id, - string name, - string? systemIdentifier, - DateTimeOffset createdAt, - long createdById, - bool enabled) - : base(id, name) + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) { - SystemIdentifier = systemIdentifier; - CreatedAt = createdAt; - this.createdById = createdById; - Enabled = enabled; + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, false, cancellationToken)); } /// /// The who created this . /// - /// A resulting in the who created this , if any. - public ValueTask CreatedBy() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// The that created this , if any. + public async ValueTask CreatedBy( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + if (!CreatedById.HasValue) + return null; + + var user = await userAuthority.InvokeTransformable(authority => authority.GetId(CreatedById.Value, false, true, cancellationToken)); + if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return new UserName(user); + + return user; + } /// /// List of s associated with the user if OAuth is configured. /// - /// A resulting in a new of s for the if OAuth is configured. - public ValueTask>? OAuthConnections() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in a new of s for the if OAuth is configured. + public ValueTask OAuthConnections( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.Invoke( + authority => authority.OAuthConnections(Id, cancellationToken)); + } /// - /// The directly associated with the , if any. + /// The associated with the . /// - /// A resulting in the directly associated with the , if any. - public ValueTask PermissionSet() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in the associated with the . + public ValueTask EffectivePermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + long lookupId; + PermissionSetLookupType lookupType; + if (GroupId.HasValue) + { + lookupId = GroupId.Value; + lookupType = PermissionSetLookupType.GroupId; + } + else + { + lookupId = Id; + lookupType = PermissionSetLookupType.UserId; + } + + return permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(lookupId, lookupType, cancellationToken)); + } + + /// + /// The owned by the , if any. + /// + /// The for the . + /// The for the operation. + /// A resulting in the owned by the , if any. + public ValueTask OwnedPermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + return permissionSetAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(Id, PermissionSetLookupType.UserId, cancellationToken)); + } /// /// The asociated with the user, if any. /// + /// The for the . + /// The for the operation. /// A resulting in the associated with the , if any. - public ValueTask Group() - => throw new NotImplementedException(); + public async ValueTask Group( + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + if (!GroupId.HasValue) + return null; + + return await userGroupAuthority.InvokeTransformable( + authority => authority.GetId(GroupId.Value, false, cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 82b62422216..2511ab438af 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,49 +1,77 @@ using System; -using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; +using HotChocolate; +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 { /// /// Represents a group of s. /// + [Node] public sealed class UserGroup : NamedEntity { /// - /// The of the . + /// Node resolver for s. /// - readonly long permissionSetId; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The value of . - public UserGroup( + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUserGroup( long id, - string name, - long permissionSetId) - : base(id, name) + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) { - this.permissionSetId = permissionSetId; + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, cancellationToken)); } /// - /// The of the . + /// The owned by the . /// - /// A resulting in the for the . - public ValueTask PermissionSet() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in the owned by the . + public async ValueTask PermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + return await permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)); + } /// - /// Gets the s in the . + /// Queries all registered s in the . /// - /// A resulting in a new of s in the . - [UsePaging(IncludeTotalCount = true)] - public List Users() - => throw new NotImplementedException(); + /// The for the . + /// A of all registered s in the . + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsersByGroup( + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable( + authority => authority + .Queryable(false) + .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 new file mode 100644 index 00000000000..eb9fc04a526 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +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. + /// + public sealed class UserGroups + { + /// + /// Gets the current . + /// + /// The for the . + /// A resulting in the current 's . + public ValueTask Current( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read()); + } + + /// + /// Gets a by . + /// + /// The of the . + /// 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, + CancellationToken cancellationToken) + => UserGroup.GetUserGroup(id, userGroupAuthority, cancellationToken); + + /// + /// Queries all registered s. + /// + /// The for the . + /// A of all registered s. + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] + public IQueryable QueryableGroups( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + return dtoQueryable; + } + + /// + /// Queries all registered s in a indicated by . + /// + /// The . + /// The for the . + /// A of all registered s in the indicated by . + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsersByGroupId( + [ID(nameof(UserGroup))]long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable( + authority => authority + .Queryable(false) + .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 new file mode 100644 index 00000000000..7175c2935ec --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -0,0 +1,57 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +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 +{ + /// + /// A with limited fields. + /// + [Node] + public sealed class UserName : NamedEntity, IUserName + { + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUserName( + long id, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, true, cancellationToken)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + [SetsRequiredMembers] + public UserName(NamedEntity copy) + : base(copy) + { + } + + /// + /// Initializes a new instance of the class. + /// + public UserName() + { + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs new file mode 100644 index 00000000000..04ac751b257 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +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; + +#pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Wrapper for accessing s. + /// + public sealed class Users + { + /// + /// Gets the swarm's . + /// + /// A new . + public UserGroups Groups() => new(); + + /// + /// Gets the current . + /// + /// The for the . + /// The for the operation. + /// A resulting in the current . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Read))] + public ValueTask Current( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); + } + + /// + /// Gets a by . + /// + /// The of the . + /// The for the . + /// 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, + CancellationToken cancellationToken) + => User.GetUser(id, userAuthority, cancellationToken); + + /// + /// Queries all registered s. + /// + /// The for the . + /// A of all registered s. + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsers( + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + return dtoQueryable; + } + } +} diff --git a/src/Tgstation.Server.Host/Models/ChatBot.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs index caf1f0c645f..908581eff57 100644 --- a/src/Tgstation.Server.Host/Models/ChatBot.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -8,7 +8,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class ChatBot : Api.Models.Internal.ChatBotSettings, IApiTransformable + public sealed class ChatBot : Api.Models.Internal.ChatBotSettings, ILegacyApiTransformable { /// /// Default for . diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 1ce860be6e1..d316f18bd3f 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class CompileJob : Api.Models.Internal.CompileJob, IApiTransformable + public sealed class CompileJob : Api.Models.Internal.CompileJob, ILegacyApiTransformable { /// /// See . diff --git a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs index 5e55dc9683b..3904c8ad5d1 100644 --- a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs +++ b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class DreamMakerSettings : Api.Models.Internal.DreamMakerSettings, IApiTransformable + public sealed class DreamMakerSettings : Api.Models.Internal.DreamMakerSettings, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs new file mode 100644 index 00000000000..0fff10e2298 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs @@ -0,0 +1,26 @@ +using System; + +#pragma warning disable CA1005 + +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents a host-side model that may be transformed into a . + /// + /// The internal model . + /// The API model . + /// The . + public interface IApiTransformable + where TApiModel : notnull + where TModel : IApiTransformable + where TTransformer : ITransformer, new() + { + /// + /// Convert the to it's . + /// + /// A new based on the . + TApiModel ToApi() + => new TTransformer() + .CompiledExpression((TModel)this); + } +} diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable.cs b/src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs similarity index 61% rename from src/Tgstation.Server.Host/Models/IApiTransformable.cs rename to src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs index e2b807b62e9..b8064202d88 100644 --- a/src/Tgstation.Server.Host/Models/IApiTransformable.cs +++ b/src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs @@ -4,12 +4,12 @@ /// Represents a host-side model that may be transformed into a . /// /// The API form of the model. - public interface IApiTransformable + public interface ILegacyApiTransformable { /// - /// Convert the to it's . + /// Convert the to it's . /// - /// A new based on the . + /// A new based on the . TApiModel ToApi(); } } diff --git a/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs b/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs new file mode 100644 index 00000000000..9da15bcfb79 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs @@ -0,0 +1,23 @@ +using System; +using System.Linq.Expressions; + +namespace Tgstation.Server.Host.Models +{ + /// + /// Contains a transformation for converting s to s. + /// + /// The input . + /// The output . + public interface ITransformer + { + /// + /// form of the transformation. + /// + Expression> Expression { get; } + + /// + /// The compiled . + /// + Func CompiledExpression { get; } + } +} diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 35434d81e1b..ded8b8fd558 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Models /// /// Represents an in the database. /// - public sealed class Instance : Api.Models.Instance, IApiTransformable + public sealed class Instance : Api.Models.Instance, ILegacyApiTransformable { /// /// Default for . diff --git a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs index 8db1f17fba4..be4e5357d12 100644 --- a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class InstancePermissionSet : Api.Models.Internal.InstancePermissionSet, IApiTransformable + public sealed class InstancePermissionSet : Api.Models.Internal.InstancePermissionSet, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index 85327de2e27..521e671be85 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Models { /// #pragma warning disable CA1724 // naming conflict with gitlab package - public sealed class Job : Api.Models.Internal.Job, IApiTransformable + public sealed class Job : Api.Models.Internal.Job, ILegacyApiTransformable #pragma warning restore CA1724 { /// diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs index d622b358c85..7c871b672a7 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -1,13 +1,18 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class OAuthConnection : Api.Models.OAuthConnection, IApiTransformable + public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTransformable { /// /// The row Id. /// public long Id { get; set; } + /// + /// The of . + /// + public long UserId { get; set; } + /// /// The owning . /// diff --git a/src/Tgstation.Server.Host/Models/PermissionSet.cs b/src/Tgstation.Server.Host/Models/PermissionSet.cs index 2c18a2ae1f1..94b769e4a83 100644 --- a/src/Tgstation.Server.Host/Models/PermissionSet.cs +++ b/src/Tgstation.Server.Host/Models/PermissionSet.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; +using Tgstation.Server.Host.Models.Transformers; + namespace Tgstation.Server.Host.Models { /// - public sealed class PermissionSet : Api.Models.PermissionSet + public sealed class PermissionSet : Api.Models.PermissionSet, IApiTransformable { /// /// The of . diff --git a/src/Tgstation.Server.Host/Models/RepositorySettings.cs b/src/Tgstation.Server.Host/Models/RepositorySettings.cs index d8041627182..9f36e2f123f 100644 --- a/src/Tgstation.Server.Host/Models/RepositorySettings.cs +++ b/src/Tgstation.Server.Host/Models/RepositorySettings.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class RepositorySettings : Api.Models.RepositorySettings, IApiTransformable + public sealed class RepositorySettings : Api.Models.RepositorySettings, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs index eb99816ab68..d683e9c8b73 100644 --- a/src/Tgstation.Server.Host/Models/RevisionInformation.cs +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, IApiTransformable + public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs index 36416403789..1aaf31594a8 100644 --- a/src/Tgstation.Server.Host/Models/TestMerge.cs +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class TestMerge : Api.Models.Internal.TestMergeApiBase, IApiTransformable + public sealed class TestMerge : Api.Models.Internal.TestMergeApiBase, ILegacyApiTransformable { /// /// See . diff --git a/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs new file mode 100644 index 00000000000..49f70f462f8 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class PermissionSetGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public PermissionSetGraphQLTransformer() + : base(model => new GraphQL.Types.PermissionSet + { + AdministrationRights = model.AdministrationRights!.Value, + InstanceManagerRights = model.InstanceManagerRights!.Value, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs new file mode 100644 index 00000000000..e3d16441ccc --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq.Expressions; + +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + abstract class TransformerBase : ITransformer + { + /// + /// cache for . + /// + static Func? compiledExpression; + + /// + public Expression> Expression { get; } + + /// + public Func CompiledExpression { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + protected TransformerBase( + Expression> expression) + { + compiledExpression ??= expression.Compile(); + Expression = expression; + CompiledExpression = compiledExpression; + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs new file mode 100644 index 00000000000..9c1184ecce3 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs @@ -0,0 +1,26 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserGraphQLTransformer() + : base(model => new GraphQL.Types.User + { + CreatedAt = model.CreatedAt!.Value, + CanonicalName = model.CanonicalName!, + CreatedById = model.CreatedById, + Enabled = model.Enabled!.Value, + GroupId = model.GroupId, + Id = model.Id!.Value, + Name = model.Name!, + SystemIdentifier = model.SystemIdentifier, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs new file mode 100644 index 00000000000..26ce4752e06 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserGroupGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserGroupGraphQLTransformer() + : base(model => new GraphQL.Types.UserGroup + { + Id = model.Id!.Value, + Name = model.Name!, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs new file mode 100644 index 00000000000..ebe63388952 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserNameGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserNameGraphQLTransformer() + : base(model => new GraphQL.Types.UserName + { + Id = model.Id!.Value, + Name = model.Name!, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index 0939dd8042c..c573368deb0 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -5,11 +5,15 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.Models { /// - public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable + public sealed class User : Api.Models.Internal.UserModelBase, + ILegacyApiTransformable, + IApiTransformable, + IApiTransformable { /// /// Username used when creating jobs automatically. @@ -26,13 +30,18 @@ public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable< /// public User? CreatedBy { get; set; } + /// + /// The of the 's . + /// + public long? CreatedById { get; set; } + /// /// The the belongs to, if any. /// public UserGroup? Group { get; set; } /// - /// The ID of the 's . + /// The of the 's . /// public long? GroupId { get; set; } diff --git a/src/Tgstation.Server.Host/Models/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs index 4451c1a0732..6a8e21c18c1 100644 --- a/src/Tgstation.Server.Host/Models/UserGroup.cs +++ b/src/Tgstation.Server.Host/Models/UserGroup.cs @@ -5,13 +5,14 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.Models { /// /// Represents a group of s. /// - public sealed class UserGroup : NamedEntity, IApiTransformable + public sealed class UserGroup : NamedEntity, ILegacyApiTransformable, IApiTransformable { /// /// The the has. diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index b23633b7dff..96f1e4b50c8 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -44,31 +44,44 @@ public Program() public static async Task Main(string[] args) { // first arg is 100% always the update path, starting it otherwise is solely for debugging purposes - string? updatePath = null; - if (args.Length > 0) - { - var listArgs = new List(args); - updatePath = listArgs.First(); - listArgs.RemoveAt(0); + var updatePath = TopLevelArgsParse(ref args); - // second arg should be host watchdog version - if (listArgs.Count > 0) - { - var expectedHostWatchdogVersion = HostWatchdogVersion; - if (Version.TryParse(listArgs.First(), out var actualHostWatchdogVersion) - && actualHostWatchdogVersion.Major != expectedHostWatchdogVersion.Major) - throw new InvalidOperationException( - $"Incompatible host watchdog version ({actualHostWatchdogVersion}) for server ({expectedHostWatchdogVersion})! A major update was released and a full restart will be required. Please manually offline your servers!"); - } + var program = new Program(); + return (int)await program.Main(args, updatePath); + } - if (listArgs.Remove("--attach-debugger")) - Debugger.Launch(); + /// + /// Parse top level . + /// + /// The arguments which may be changed. + /// The update path for the server, if present. + static string? TopLevelArgsParse(ref string[] args) + { + if (args.Length == 0) + return null; + + var potentialUpdatePath = args[0]; + if (potentialUpdatePath.Equals("cli", StringComparison.OrdinalIgnoreCase)) + return null; - args = listArgs.ToArray(); + var listArgs = new List(args); + listArgs.RemoveAt(0); + + // second arg should be host watchdog version + if (listArgs.Count > 0) + { + var expectedHostWatchdogVersion = HostWatchdogVersion; + if (Version.TryParse(listArgs.First(), out var actualHostWatchdogVersion) + && actualHostWatchdogVersion.Major != expectedHostWatchdogVersion.Major) + throw new InvalidOperationException( + $"Incompatible host watchdog version ({actualHostWatchdogVersion}) for server ({expectedHostWatchdogVersion})! A major update was released and a full restart will be required. Please manually offline your servers!"); } - var program = new Program(); - return (int)await program.Main(args, updatePath); + if (listArgs.Remove("--attach-debugger")) + Debugger.Launch(); + + args = [.. listArgs]; + return potentialUpdatePath; } /// diff --git a/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs b/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs index 47d191b80a7..23e85d3ef15 100644 --- a/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs +++ b/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs @@ -1,6 +1,10 @@ using System.Runtime.CompilerServices; +using GreenDonut; + [assembly: InternalsVisibleTo("Tgstation.Server.Host.Tests")] [assembly: InternalsVisibleTo("Tgstation.Server.Host.Tests.Signals")] [assembly: InternalsVisibleTo("Tgstation.Server.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] + +[assembly: DataLoaderDefaults(AccessModifier = DataLoaderAccessModifier.Internal)] diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 53bf23c2a21..8659db7d564 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -13,10 +13,10 @@ sealed class AuthenticationContext : IAuthenticationContext, IDisposable public bool Valid { get; private set; } /// - public User User => user ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public User User => user ?? throw InvalidContext(); /// - public PermissionSet PermissionSet => permissionSet ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public PermissionSet PermissionSet => permissionSet ?? throw InvalidContext(); /// public InstancePermissionSet? InstancePermissionSet { get; private set; } @@ -24,6 +24,12 @@ sealed class AuthenticationContext : IAuthenticationContext, IDisposable /// public ISystemIdentity? SystemIdentity { get; private set; } + /// + public DateTimeOffset SessionExpiry => sessionExpiry ?? throw InvalidContext(); + + /// + public string SessionId => sessionId ?? throw InvalidContext(); + /// /// Backing field for . /// @@ -34,6 +40,16 @@ sealed class AuthenticationContext : IAuthenticationContext, IDisposable /// PermissionSet? permissionSet; + /// + /// Backing field for . + /// + DateTimeOffset? sessionExpiry; + + /// + /// Backing field for . + /// + string? sessionId; + /// /// Initializes a new instance of the class. /// @@ -41,25 +57,44 @@ public AuthenticationContext() { } + /// + /// for accessing fields on an In . + /// + /// A new . + static InvalidOperationException InvalidContext() + => new("AuthenticationContext is invalid!"); + /// public void Dispose() => SystemIdentity?.Dispose(); /// /// Initializes the . /// - /// The value of . /// The value of . + /// The value of . + /// The value of . /// The value of . - public void Initialize(ISystemIdentity? systemIdentity, User user, InstancePermissionSet? instanceUser) + /// The value of . + public void Initialize( + User user, + DateTimeOffset sessionExpiry, + string sessionId, + InstancePermissionSet? instanceUser, + ISystemIdentity? systemIdentity) { - this.user = user ?? throw new ArgumentNullException(nameof(user)); - if (systemIdentity == null && User.SystemIdentifier != null) + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(sessionId); + if (systemIdentity == null && user.SystemIdentifier != null) throw new ArgumentNullException(nameof(systemIdentity)); + permissionSet = user.PermissionSet ?? user.Group!.PermissionSet ?? throw new ArgumentException("No PermissionSet provider", nameof(user)); + this.user = user; InstancePermissionSet = instanceUser; SystemIdentity = systemIdentity; + this.sessionId = sessionId; + this.sessionExpiry = sessionExpiry; Valid = true; } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index d649e2f7fff..d8a47f782fe 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -1,17 +1,12 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.IdentityModel.Tokens; -using Tgstation.Server.Api; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -21,76 +16,40 @@ namespace Tgstation.Server.Host.Security sealed class AuthenticationContextClaimsTransformation : IClaimsTransformation { /// - /// The for the . + /// The for the . /// - readonly IAuthenticationContextFactory authenticationContextFactory; - - /// - /// The for the . - /// - readonly ApiHeaders? apiHeaders; + readonly IAuthenticationContext authenticationContext; /// /// Initializes a new instance of the class. /// - /// The value of . - /// The containing the value of . - public AuthenticationContextClaimsTransformation(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider) + /// The value of . + public AuthenticationContextClaimsTransformation(IAuthenticationContext authenticationContext) { - this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); - ArgumentNullException.ThrowIfNull(apiHeadersProvider); - apiHeaders = apiHeadersProvider.ApiHeaders; + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); } /// - public async Task TransformAsync(ClaimsPrincipal principal) + public Task TransformAsync(ClaimsPrincipal principal) { ArgumentNullException.ThrowIfNull(principal); - 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 nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf); - if (nbfClaim == default) - throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!"); - - DateTimeOffset nbf; - try - { - nbf = new DateTimeOffset( - EpochTime.DateTime( - Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture))); - } - catch (Exception ex) - { - throw new InvalidOperationException("Failed to parse nbf!", ex); - } - - var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext( - userId, - apiHeaders?.InstanceId, - nbf, - CancellationToken.None); // DCT: None available + if (!authenticationContext.Valid) + throw new InvalidOperationException("Expected a valid authentication context here!"); var enumerator = Enum.GetValues(typeof(RightsType)); var claims = new List(); + if (authenticationContext.User.Require(x => x.Enabled)) + claims.Add( + new Claim( + ClaimTypes.Role, + TgsAuthorizeAttribute.UserEnabledRole)); + foreach (RightsType rightType in enumerator) { // if there's a bad condition, do a weird thing and add all the roles // we need it so we can get to TgsAuthorizeAttribute where we can properly decide between BadRequest and Forbid - var rightAsULong = !authenticationContext.Valid - || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) + var rightAsULong = (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) ? ~0UL : authenticationContext.GetRight(rightType); var rightEnum = RightsHelper.RightToType(rightType); @@ -105,7 +64,7 @@ public async Task TransformAsync(ClaimsPrincipal principal) principal.AddIdentity(new ClaimsIdentity(claims)); - return principal; + return Task.FromResult(principal); } } } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 00df5df54fa..ba47b47a44e 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -1,20 +1,27 @@ using System; +using System.Globalization; using System.Linq; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; 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.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Security { /// - sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisposable + sealed class AuthenticationContextFactory : ITokenValidator, IDisposable { /// /// The the created. @@ -46,6 +53,11 @@ sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisp /// readonly AuthenticationContext currentAuthenticationContext; + /// + /// The for the . + /// + readonly ApiHeaders? apiHeaders; + /// /// 1 if was initialized, 0 otherwise. /// @@ -56,16 +68,21 @@ sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisp /// /// The value of . /// The value of . + /// The containing the value of . /// The containing the value of . /// The value of . public AuthenticationContextFactory( IDatabaseContext databaseContext, IIdentityCache identityCache, + IApiHeadersProvider apiHeadersProvider, IOptions swarmConfigurationOptions, ILogger logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); + ArgumentNullException.ThrowIfNull(apiHeadersProvider); + + apiHeaders = apiHeadersProvider.ApiHeaders; swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -76,11 +93,55 @@ public AuthenticationContextFactory( public void Dispose() => currentAuthenticationContext.Dispose(); /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken) + #pragma warning disable CA1506 // TODO: Decomplexify + public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + #pragma warning restore CA1506 { + ArgumentNullException.ThrowIfNull(tokenValidatedContext); + + if (tokenValidatedContext.SecurityToken is not JsonWebToken jwt) + throw new ArgumentException($"Expected {nameof(tokenValidatedContext)} to contain a {nameof(JsonWebToken)}!", nameof(tokenValidatedContext)); + if (Interlocked.Exchange(ref initialized, 1) != 0) throw new InvalidOperationException("Authentication context has already been loaded"); + var principal = new ClaimsPrincipal(new ClaimsIdentity(jwt.Claims)); + + 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); + } + + DateTimeOffset ParseTime(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); + } + } + + var notBefore = ParseTime(JwtRegisteredClaimNames.Nbf); + var expires = ParseTime(JwtRegisteredClaimNames.Exp); + var user = await databaseContext .Users .AsQueryable() @@ -93,8 +154,8 @@ public async ValueTask CreateAuthenticationContext(long .FirstOrDefaultAsync(cancellationToken); if (user == default) { - logger.LogWarning("Unable to find user with ID {userId}!", userId); - return currentAuthenticationContext; + tokenValidatedContext.Fail($"Unable to find user with ID {userId}!"); + return; } ISystemIdentity? systemIdentity; @@ -104,8 +165,8 @@ public async ValueTask CreateAuthenticationContext(long { if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate >= notBefore) { - logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value); - return currentAuthenticationContext; + tokenValidatedContext.Fail($"Rejecting token for user {userId} created before last modification: {user.LastPasswordUpdate.Value}"); + return; } systemIdentity = null; @@ -115,6 +176,7 @@ public async ValueTask CreateAuthenticationContext(long try { InstancePermissionSet? instancePermissionSet = null; + var instanceId = apiHeaders?.InstanceId; if (instanceId.HasValue) { instancePermissionSet = await databaseContext.InstancePermissionSets @@ -128,10 +190,11 @@ public async ValueTask CreateAuthenticationContext(long } currentAuthenticationContext.Initialize( - systemIdentity, user, - instancePermissionSet); - return currentAuthenticationContext; + expires, + jwt.EncodedSignature, // signature is enough to uniquely identify the session as it is composite of all the inputs + instancePermissionSet, + systemIdentity); } catch { diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 8277a6601d2..d827185dd27 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -1,17 +1,29 @@ -using Tgstation.Server.Api.Rights; +using System; + +using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { /// - /// Represents the currently authenticated . + /// For creating and accessing authentication contexts. /// public interface IAuthenticationContext { /// /// If the is for a valid login. /// - public bool Valid { get; } + bool Valid { get; } + + /// + /// A that uniquely identifies the login session. + /// + string SessionId { get; } + + /// + /// When the login session expires. + /// + DateTimeOffset SessionExpiry { get; } /// /// The authenticated user. diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs deleted file mode 100644 index cb70a50b18e..00000000000 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Host.Security -{ - /// - /// For creating and accessing authentication contexts. - /// - public interface IAuthenticationContextFactory - { - /// - /// Create an in the request pipeline for a given and . - /// - /// The of the . - /// The of the for the operation. - /// The the login must not be from before. - /// The for the operation. - /// A resulting in the created . - ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs new file mode 100644 index 00000000000..ac4cec0b818 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs @@ -0,0 +1,22 @@ +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Handles invalidating user sessions. + /// + public interface ISessionInvalidationTracker + { + /// + /// Invalidate all sessions for a given . + /// + /// The whose sessions should be invalidated. + public void UserModifiedInvalidateSessions(User user); + + /// + /// Track the session represented by a given . + /// + /// The representing the session to track. + public void TrackSession(IAuthenticationContext authenticationContext); + } +} diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 707c5d78ff9..777122a2ede 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -26,7 +26,7 @@ public interface ITokenFactory /// /// The to create the token for. Must have the field available. /// Whether or not this is an OAuth login. - /// A new . - TokenResponse CreateToken(Models.User user, bool oAuth); + /// A new token . + string CreateToken(Models.User user, bool oAuth); } } diff --git a/src/Tgstation.Server.Host/Security/ITokenValidator.cs b/src/Tgstation.Server.Host/Security/ITokenValidator.cs new file mode 100644 index 00000000000..c4fc4f2e4fa --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ITokenValidator.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Handles s. + /// + public interface ITokenValidator + { + /// + /// Handles . + /// + /// The . + /// The for the operation. + /// A representing the running operation. + Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs new file mode 100644 index 00000000000..5f5157e3619 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate.Subscriptions; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Host.GraphQL; +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class SessionInvalidationTracker : ISessionInvalidationTracker + { + /// + /// The for the . + /// + readonly ITopicEventSender eventSender; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly IHostApplicationLifetime applicationLifetime; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// of tracked s and s to the for their s. + /// + readonly ConcurrentDictionary<(string SessionId, long UserId), TaskCompletionSource> trackedSessions; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public SessionInvalidationTracker( + ITopicEventSender eventSender, + IAsyncDelayer asyncDelayer, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + { + this.eventSender = eventSender ?? throw new ArgumentNullException(nameof(eventSender)); + this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); + this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + trackedSessions = new ConcurrentDictionary<(string, long), TaskCompletionSource>(); + } + + /// + public void TrackSession(IAuthenticationContext authenticationContext) + { + trackedSessions.GetOrAdd( + (authenticationContext.SessionId, authenticationContext.User.Require(x => x.Id)), + tuple => + { + var (localSessionId, localUserId) = tuple; + logger.LogTrace("Tracking session ID for user {userId}: {sessionId}", localUserId, localSessionId); + var tcs = new TaskCompletionSource(); + async void SendInvalidationTopic() + { + try + { + SessionInvalidationReason invalidationReason; + try + { + var otherCancellationReason = tcs.Task; + var timeTillSessionExpiry = authenticationContext.SessionExpiry - DateTimeOffset.UtcNow; + if (timeTillSessionExpiry > TimeSpan.Zero) + { + var delayTask = asyncDelayer.Delay(timeTillSessionExpiry, applicationLifetime.ApplicationStopping); + + await Task.WhenAny(delayTask, otherCancellationReason); + + if (delayTask.IsCompleted) + await delayTask; + } + + invalidationReason = otherCancellationReason.IsCompleted + ? await otherCancellationReason + : SessionInvalidationReason.TokenExpired; + + logger.LogTrace("Invalidating session ID {sessionID}: {reason}", localSessionId, invalidationReason); + } + catch (OperationCanceledException ex) + { + logger.LogTrace(ex, "Invalidating session ID {sessionID} due to server shutdown", localSessionId); + invalidationReason = SessionInvalidationReason.ServerShutdown; + } + + var topicName = Subscription.SessionInvalidatedTopic(authenticationContext); + await eventSender.SendAsync(topicName, invalidationReason, CancellationToken.None); // DCT: Session close messages should always be sent + await eventSender.CompleteAsync(topicName); + } + catch (Exception ex) + { + logger.LogError(ex, "Error tracking session {sessionId}!", localSessionId); + } + } + + SendInvalidationTopic(); + return tcs; + }); + } + + /// + public void UserModifiedInvalidateSessions(Models.User user) + { + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Require(x => x.Id); + user.LastPasswordUpdate = DateTimeOffset.UtcNow; + + foreach (var key in trackedSessions + .Keys + .Where(key => key.UserId == userId) + .ToList()) + if (trackedSessions.TryRemove(key, out var tcs)) + tcs.TrySetResult(SessionInvalidationReason.UserUpdated); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index 0dda24b4723..d03c2f9e6dc 100644 --- a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs @@ -1,13 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -16,8 +13,13 @@ namespace Tgstation.Server.Host.Security /// #pragma warning disable CA1019 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - sealed class TgsAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter + sealed class TgsAuthorizeAttribute : AuthorizeAttribute { + /// + /// Role used to indicate access to the server is allowed. + /// + public const string UserEnabledRole = "Core.UserEnabled"; + /// /// Gets the associated with the if any. /// @@ -27,6 +29,7 @@ sealed class TgsAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter /// Initializes a new instance of the class. /// public TgsAuthorizeAttribute() + : this(Enumerable.Empty()) { } @@ -35,8 +38,8 @@ public TgsAuthorizeAttribute() /// /// The required. public TgsAuthorizeAttribute(AdministrationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Administration; } @@ -45,8 +48,8 @@ public TgsAuthorizeAttribute(AdministrationRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(InstanceManagerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.InstanceManager; } @@ -55,8 +58,8 @@ public TgsAuthorizeAttribute(InstanceManagerRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(RepositoryRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Repository; } @@ -65,8 +68,8 @@ public TgsAuthorizeAttribute(RepositoryRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(EngineRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Engine; } @@ -75,8 +78,8 @@ public TgsAuthorizeAttribute(EngineRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(DreamMakerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.DreamMaker; } @@ -85,8 +88,8 @@ public TgsAuthorizeAttribute(DreamMakerRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.DreamDaemon; } @@ -95,8 +98,8 @@ public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(ChatBotRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.ChatBots; } @@ -105,8 +108,8 @@ public TgsAuthorizeAttribute(ChatBotRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(ConfigurationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Configuration; } @@ -115,30 +118,20 @@ public TgsAuthorizeAttribute(ConfigurationRights requiredRights) /// /// The required. public TgsAuthorizeAttribute(InstancePermissionSetRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.InstancePermissionSet; } - /// - public void OnAuthorization(AuthorizationFilterContext context) + /// + /// Initializes a new instance of the class. + /// + /// An of roles to be required alongside the . + private TgsAuthorizeAttribute(IEnumerable roles) { - var services = context.HttpContext.RequestServices; - var authenticationContext = services.GetRequiredService(); - var logger = services.GetRequiredService>(); - - if (!authenticationContext.Valid) - { - logger.LogTrace("authenticationContext is invalid!"); - context.Result = new UnauthorizedResult(); - return; - } - - if (authenticationContext.User.Require(x => x.Enabled)) - return; - - logger.LogTrace("authenticationContext is for a disabled user!"); - context.Result = new ForbidResult(); + var listRoles = roles.ToList(); + listRoles.Add(UserEnabledRole); + Roles = String.Join(",", listRoles); } } } diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs new file mode 100644 index 00000000000..e03e7e60814 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -0,0 +1,133 @@ +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(); + listRoles.Add(TgsAuthorizeAttribute.UserEnabledRole); + Roles = [.. listRoles]; + Apply = ApplyPolicy.Validation; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs new file mode 100644 index 00000000000..495424073af --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000000..6103eca33b2 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,42 @@ +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/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index d2ab3c05279..9269472c87c 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -101,7 +101,7 @@ public TokenFactory( } /// - public TokenResponse CreateToken(User user, bool oAuth) + public string CreateToken(User user, bool oAuth) { ArgumentNullException.ThrowIfNull(user); @@ -139,10 +139,7 @@ public TokenResponse CreateToken(User user, bool oAuth) expiry.UtcDateTime, now.UtcDateTime)); - var tokenResponse = new TokenResponse - { - Bearer = tokenHandler.WriteToken(securityToken), - }; + var tokenResponse = tokenHandler.WriteToken(securityToken); return tokenResponse; } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index 9884ac71939..b6a0e51df65 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -211,7 +211,7 @@ public SwarmService( swarmServers = new List { - new SwarmServerInformation + new() { Address = swarmConfiguration.Address, PublicAddress = swarmConfiguration.PublicAddress, diff --git a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs index 48924155719..9bbcd497ce0 100644 --- a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs +++ b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs @@ -107,7 +107,7 @@ public void RegisterProcess(IProcess process) { if (registeredProcesses.Contains(process)) throw new InvalidOperationException("This process has already been registered for network prompt reaping!"); - logger.LogTrace("Registering process {0}...", process.Id); + logger.LogTrace("Registering process {pid}...", process.Id); registeredProcesses.Add(process); } @@ -148,7 +148,7 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) continue; // not our bitch } - logger.LogTrace("Identified \"Network Accessibility\" window in owned process {0}", processId); + logger.LogTrace("Identified \"Network Accessibility\" window in owned process {pid}", processId); var found = false; foreach (var childHandle in GetAllChildHandles(window)) @@ -179,7 +179,7 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) } if (!found) - logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {0}!", processId); + logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {pid}!", processId); } } catch (OperationCanceledException ex) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 49c85ec6e38..5e1b0deb062 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -101,11 +101,15 @@ - + - + + + + + - + @@ -126,6 +130,7 @@ + @@ -181,8 +186,6 @@ - - diff --git a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs index dc15d9db687..d415e30ea31 100644 --- a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs @@ -270,7 +270,7 @@ Tuple> G .OrderBy(x => x.PutOnly) // Process PUTs last .ToList(); - if (requestOptions.Any() && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly)) + if (requestOptions.Count == 0 && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly)) subSchema.ReadOnly = true; var subSchemaId = tuple.Item2; @@ -370,7 +370,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) operation.Security = new List { - new OpenApiSecurityRequirement + new() { { tokenScheme, @@ -450,7 +450,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) operation.Security = new List { - new OpenApiSecurityRequirement + new() { { passwordScheme, diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index 1e0d7b7fa8f..3ffee423826 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -13,32 +13,6 @@ namespace Tgstation.Server.Host.Security.Tests [TestClass] public sealed class TestAuthenticationContext { - [TestMethod] - public void TestConstruction() - { - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, null)); - var mockSystemIdentity = new Mock(); - - var user = new User() - { - PermissionSet = new PermissionSet() - }; - - var authContext = new AuthenticationContext(); - Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, null)); - - var instanceUser = new InstancePermissionSet(); - - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, instanceUser)); - Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, instanceUser)); - new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, null); - new AuthenticationContext().Initialize(null, user, instanceUser); - new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, instanceUser); - user.SystemIdentifier = "root"; - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, user, null)); - } - - [TestMethod] public void TestGetRightsGeneric() { @@ -48,7 +22,7 @@ public void TestGetRightsGeneric() }; var instanceUser = new InstancePermissionSet(); var authContext = new AuthenticationContext(); - authContext.Initialize(null, user, instanceUser); + authContext.Initialize(user, DateTimeOffset.UtcNow, "asdf", instanceUser, null); user.PermissionSet.AdministrationRights = AdministrationRights.WriteUsers; instanceUser.EngineRights = EngineRights.InstallOfficialOrChangeActiveByondVersion | EngineRights.ReadActive; diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs index 2fbc888b1b3..c5f22614b84 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs @@ -89,7 +89,7 @@ public ReadOnlySpan SigningKeyBytes public TokenValidationParameters ValidationParameters => throw new NotSupportedException(); - public TokenResponse CreateToken(User user, bool oAuth) + public string CreateToken(User user, bool oAuth) { throw new NotSupportedException(); } diff --git a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 1fe6742015a..5bd736c6cce 100644 --- a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs @@ -1,9 +1,15 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + using Tgstation.Server.Client; +using Tgstation.Server.Client.GraphQL; namespace Tgstation.Server.Tests.Live { @@ -19,7 +25,7 @@ static class ApiAssert /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func action, ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsException(Func action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try @@ -43,7 +49,7 @@ public static async ValueTask ThrowsException(Func act /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func> action, ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsException(Func> action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try @@ -58,5 +64,25 @@ public static async ValueTask ThrowsException(Func( + IGraphQLServerClient client, + Func>> operationInvoker, + Func payloadSelector, + Client.GraphQL.ErrorCode expectedErrorCode, + CancellationToken cancellationToken) + where TResultData : class + { + var operationResult = await client.RunOperation(operationInvoker, cancellationToken); + operationResult.EnsureNoErrors(); + + var payload = payloadSelector(operationResult.Data); + + var payloadErrors = (IEnumerable)payload.GetType().GetProperty("Errors").GetValue(payload); + var error = payloadErrors.Single(); + + var errorCode = (ErrorCode)error.GetType().GetProperty("ErrorCode").GetValue(error); + Assert.AreEqual(expectedErrorCode, errorCode); + } } } diff --git a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs new file mode 100644 index 00000000000..993b8999ad9 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + +using Tgstation.Server.Client.GraphQL; + +namespace Tgstation.Server.Tests.Live +{ + static class GraphQLServerClientExtensions + { + public static async ValueTask RunQueryEnsureNoErrors( + this IGraphQLServerClient serverClient, + Func>> operationExecutor, + CancellationToken cancellationToken) + where TResultData : class + { + var result = await serverClient.RunOperation(operationExecutor, cancellationToken); + result.EnsureNoErrors(); + return result.Data; + } + + public static async ValueTask RunMutationEnsureNoErrors( + this IGraphQLServerClient serverClient, + Func>> operationExecutor, + Func payloadSelector, + CancellationToken cancellationToken) + where TResultData : class + { + var result = await serverClient.RunOperation(operationExecutor, cancellationToken); + result.EnsureNoErrors(); + var data = payloadSelector(result.Data); + var errorsObject = data.GetType().GetProperty("Errors").GetValue(data); + if (errorsObject != null) + { + var errorsCount = (int)errorsObject.GetType().GetProperty("Count").GetValue(errorsObject); + + Assert.AreEqual(0, errorsCount); + } + + return data; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs new file mode 100644 index 00000000000..e5ee2b32d89 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs @@ -0,0 +1,34 @@ +using System; + +namespace Tgstation.Server.Tests.Live +{ + sealed class HoldLastObserver : IObserver + { + public bool Completed { get; private set; } + + public Exception LastError { get; private set; } + + public T LastValue { get; private set; } + + public ulong ErrorCount { get; private set; } + + public ulong ResultCount { get; private set; } + + public void OnCompleted() + { + Completed = true; + } + + public void OnError(Exception error) + { + ++ErrorCount; + LastError = error; + } + + public void OnNext(T value) + { + ++ResultCount; + LastValue = value; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs new file mode 100644 index 00000000000..315052e1a26 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs @@ -0,0 +1,34 @@ +using StrawberryShake; +using System.Threading.Tasks; +using System.Threading; +using System; +using Tgstation.Server.Client.GraphQL; +using Tgstation.Server.Client; + +namespace Tgstation.Server.Tests.Live +{ + interface IMultiServerClient + { + ValueTask Execute( + Func restAction, + Func graphQLAction); + + ValueTask<(TRestResult, TGraphQLResult)> ExecuteReadOnlyConfirmEquivalence( + Func> restAction, + Func>> graphQLAction, + Func comparison, + CancellationToken cancellationToken) + where TGraphQLResult : class; + + /// + /// Subcribes to the GraphQL subscription indicated by . + /// + /// The of the 's . + /// A which initiates a single subscription on a given and returns a resulting in the . + /// The for s. + /// The for the operation. + /// A resulting in the representing the lifetime of the subscription. + ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class; + } +} diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index dc8c71753d0..f2bb84c639a 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -34,10 +34,10 @@ sealed class JobsHubTests : IJobsHub long? permlessPsId; - public JobsHubTests(IRestServerClient permedUser, IRestServerClient permlessUser) + public JobsHubTests(MultiServerClient permedUser, MultiServerClient permlessUser) { - this.permedUser = permedUser; - this.permlessUser = permlessUser; + this.permedUser = permedUser.RestClient; + this.permlessUser = permlessUser.RestClient; Assert.AreNotSame(permedUser, permlessUser); diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 801f4451bdc..5dbf153d784 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,48 +1,65 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using StrawberryShake; + using Tgstation.Server.Client; using Tgstation.Server.Client.GraphQL; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient + sealed class MultiServerClient : IMultiServerClient, IAsyncDisposable { - readonly IRestServerClient restServerClient; - readonly IGraphQLServerClient graphQLServerClient; - - readonly bool useGraphQL; + public IRestServerClient RestClient { get; } + public IGraphQLServerClient GraphQLClient { get; } public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) { - this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); - this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); - this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + this.RestClient = restServerClient; + this.GraphQLClient = graphQLServerClient; } + public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + + public ValueTask DisposeAsync() + => ValueTaskExtensions.WhenAll( + RestClient.DisposeAsync(), + GraphQLClient.DisposeAsync()); + public ValueTask Execute( Func restAction, - Func graphQLAction) + Func graphQLAction) { - if (useGraphQL) - return graphQLServerClient.RunQuery(graphQLAction); + if (UseGraphQL) + return graphQLAction(GraphQLClient); - return restAction(restServerClient); + return restAction(RestClient); } - public async ValueTask ExecuteReadOnlyConfirmEquivalence( + public async ValueTask<(TRestResult, TGraphQLResult)> ExecuteReadOnlyConfirmEquivalence( Func> restAction, - Func> graphQLAction, - Func comparison) + Func>> graphQLAction, + Func comparison, + CancellationToken cancellationToken) + where TGraphQLResult : class { - var restTask = restAction(this.restServerClient); - TGraphQLResult graphQLResult = default; - await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient)); + var restTask = restAction(RestClient); + var graphQLResult = await GraphQLClient.RunOperation(graphQLAction, cancellationToken); + + graphQLResult.EnsureNoErrors(); var restResult = await restTask; - Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!"); + var comparisonResult = comparison(restResult, graphQLResult.Data); + Assert.IsTrue(comparisonResult, "REST/GraphQL results differ!"); + + return (restResult, graphQLResult.Data); } + + public ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) where TResultData : class + => GraphQLClient.Subscribe(operationExecutor, observer, cancellationToken); } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 5083ff6956c..69921a4b914 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; using System; using System.Net; @@ -26,6 +26,9 @@ using Tgstation.Server.Client.Extensions; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host; +using Tgstation.Server.Client.GraphQL; +using System.Linq; +using StrawberryShake; namespace Tgstation.Server.Tests.Live { @@ -63,7 +66,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -90,7 +93,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ApiMismatch, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, string.Concat(url.ToString(), Routes.Administration.AsSpan(1)))) @@ -104,7 +107,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ApiMismatch, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, string.Concat(url.ToString(), Routes.Administration.AsSpan(1)))) @@ -122,7 +125,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, string.Concat(url.ToString(), Routes.DreamDaemon.AsSpan(1)))) @@ -146,7 +149,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.InstanceHeaderRequired, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -160,7 +163,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -175,7 +178,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -192,7 +195,7 @@ static async Task TestRequestValidation(IRestServerClient serverClient, Cancella Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } } @@ -228,7 +231,7 @@ static async Task TestOAuthFails(IRestServerClient serverClient, CancellationTok using var httpClient = new HttpClient(); // just hitting each type of oauth provider for coverage - foreach (var I in Enum.GetValues(typeof(OAuthProvider))) + foreach (var I in Enum.GetValues(typeof(Api.Models.OAuthProvider))) using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.ApiRoot.TrimStart('/'))) { request.Headers.Accept.Clear(); @@ -261,7 +264,7 @@ static async Task TestInvalidTransfers(IRestServerClient serverClient, Cancellat var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Put, string.Concat(url.ToString(), Routes.Transfer.AsSpan(1)))) @@ -276,7 +279,7 @@ static async Task TestInvalidTransfers(IRestServerClient serverClient, Cancellat var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, string.Concat(url.ToString(), Routes.Transfer.AsSpan(1), "?ticket=veryfaketicket"))) @@ -444,12 +447,7 @@ await serverClient.Users.Update(new UserUpdateRequest Assert.AreNotEqual(HubConnectionState.Connected, testUserConn1.State); - await using var testUserConn2 = (HubConnection)await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); - - for (var i = 0; i < 10 && testUserConn2.State == HubConnectionState.Connected; ++i) - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - - Assert.AreNotEqual(HubConnectionState.Connected, testUserConn2.State); + await ApiAssert.ThrowsException(async () => await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken)); } finally { @@ -457,9 +455,27 @@ await serverClient.Users.Update(new UserUpdateRequest } } + static async Task TestGraphQLLogin(IRestServerClientFactory clientFactory, IRestServerClient restClient, CancellationToken cancellationToken) + { + await using var gqlClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(restClient.Url); + var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken); + + Assert.IsNotNull(result.Data); + Assert.IsNull(result.Data.Login.Bearer); + Assert.IsNotNull(result.Data.Login.Errors); + Assert.AreEqual(1, result.Data.Login.Errors.Count); + var castResult = result.Data.Login.Errors[0] is ILogin_Login_Errors_ErrorMessageError loginError; + Assert.IsTrue(castResult); + loginError = (ILogin_Login_Errors_ErrorMessageError)result.Data.Login.Errors[0]; + Assert.AreEqual(Client.GraphQL.ErrorCode.BadHeaders, loginError.ErrorCode.Value); + Assert.IsNotNull(loginError.Message); + Assert.IsNotNull(loginError.AdditionalData); + } + public static Task Run(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken) => Task.WhenAll( TestRequestValidation(serverClient, cancellationToken), + TestGraphQLLogin(clientFactory, serverClient, cancellationToken), TestOAuthFails(serverClient, cancellationToken), TestServerInformation(clientFactory, serverClient, cancellationToken), TestInvalidTransfers(serverClient, cancellationToken), diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 75f7c88875e..0dccd54a29f 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -27,6 +27,8 @@ using Npgsql; +using StrawberryShake; + using Tgstation.Server.Api; using Tgstation.Server.Api.Extensions; using Tgstation.Server.Api.Models; @@ -43,7 +45,6 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.System; -using Tgstation.Server.Host.Utils; using Tgstation.Server.Tests.Live.Instance; namespace Tgstation.Server.Tests.Live @@ -53,17 +54,19 @@ namespace Tgstation.Server.Tests.Live [TestCategory("RequiresDatabase")] public sealed class TestLiveServer { + const ushort InitialPort = 42069; public static readonly Version TestUpdateVersion = new(5, 11, 0); static readonly Lazy odDMPort = new(() => FreeTcpPort()); - static readonly Lazy odDDPort = new(() => FreeTcpPort(odDMPort.Value)); - static readonly Lazy compatDMPort = new(() => FreeTcpPort(odDDPort.Value, odDMPort.Value)); - static readonly Lazy compatDDPort = new(() => FreeTcpPort(odDDPort.Value, odDMPort.Value, compatDMPort.Value)); - static readonly Lazy mainDDPort = new(() => FreeTcpPort(odDDPort.Value, odDMPort.Value, compatDMPort.Value, compatDDPort.Value)); - static readonly Lazy mainDMPort = new(() => FreeTcpPort(odDDPort.Value, odDMPort.Value, compatDMPort.Value, compatDDPort.Value, mainDDPort.Value)); + static readonly Lazy odDDPort = new(() => FreeTcpPort()); + static readonly Lazy compatDMPort = new(() => FreeTcpPort()); + static readonly Lazy compatDDPort = new(() => FreeTcpPort()); + static readonly Lazy mainDDPort = new(() => FreeTcpPort()); + static readonly Lazy mainDMPort = new(() => FreeTcpPort()); static void InitializePorts() { + tcpPortCounter = InitialPort; _ = odDMPort.Value; _ = odDDPort.Value; _ = compatDMPort.Value; @@ -72,7 +75,14 @@ static void InitializePorts() _ = mainDMPort.Value; } - readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + readonly RestServerClientFactory restClientFactory; + readonly GraphQLServerClientFactory graphQLClientFactory; + + public TestLiveServer() + { + restClientFactory = new(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); + } public static List GetEngineServerProcessesOnPort(EngineType engineType, ushort? port) { @@ -156,41 +166,14 @@ static bool TerminateAllEngineServers() return result; } - static ushort FreeTcpPort(params ushort[] usedPorts) - { - ushort result; - var listeners = new List(); + static int tcpPortCounter = InitialPort; - try - { - do - { - var l = new TcpListener(IPAddress.Any, 0); - l.Start(); - try - { - listeners.Add(l); - } - catch - { - using (l) - l.Stop(); - throw; - } - - result = (ushort)((IPEndPoint)l.LocalEndpoint).Port; - } - while (usedPorts.Contains(result) || result < 20000); - } - finally - { - foreach (var l in listeners) - using (l) - l.Stop(); - } + static ushort FreeTcpPort() + { + var result = Interlocked.Increment(ref tcpPortCounter); Console.WriteLine($"Allocated port: {result}"); - return result; + return (ushort)result; } [ClassInitialize] @@ -321,18 +304,18 @@ async ValueTask TestWithoutAndWithPermission(Func(content); - Assert.AreEqual(ErrorCode.OAuthProviderDisabled, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OAuthProviderDisabled, message.ErrorCode); } //attempt to update to stable var responseModel = await TestWithoutAndWithPermission( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -340,7 +323,7 @@ async ValueTask TestWithoutAndWithPermission(Func TestWithoutAndWithPermission(Func adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -410,7 +393,7 @@ async Task CheckUpdate() }, downloadStream, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.UploadVersion); Assert.IsNotNull(responseModel); @@ -450,14 +433,14 @@ public async Task TestUpdateBadVersion() var testUpdateVersion = new Version(5, 11, 20); await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); await ApiAssert.ThrowsException( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = testUpdateVersion }, null, cancellationToken), - ErrorCode.ResourceNotPresent); + Api.Models.ErrorCode.ResourceNotPresent); } finally { @@ -500,7 +483,7 @@ public async Task TestOneServerSwarmUpdate() { await using var controllerClient = await CreateAdminClient(controller.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); static void CheckInfo(ServerInformationResponse serverInformation) { @@ -515,7 +498,7 @@ static void CheckInfo(ServerInformationResponse serverInformation) CheckInfo(controllerInfo); // test update - var responseModel = await controllerClient.Administration.Update( + var responseModel = await controllerClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -603,7 +586,7 @@ public async Task TestSwarmSynchronizationAndUpdates() await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate() { @@ -611,7 +594,7 @@ async Task WaitForSwarmServerUpdate() do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node1Client.ServerInformation(cancellationToken); + serverInformation = await node1Client.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -644,13 +627,13 @@ await Task.WhenAny( WaitForSwarmServerUpdate(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); // check user info is shared - var newUser = await node2Client.Users.Create(new UserCreateRequest + var newUser = await node2Client.RestClient.Users.Create(new UserCreateRequest { Name = "asdf", Password = "asdfasdfasdfasdf", @@ -661,20 +644,20 @@ await Task.WhenAny( } }, cancellationToken); - var node1User = await node1Client.Users.GetId(newUser, cancellationToken); + var node1User = await node1Client.RestClient.Users.GetId(newUser, cancellationToken); Assert.AreEqual(newUser.Name, node1User.Name); Assert.AreEqual(newUser.Enabled, node1User.Enabled); - await using var controllerUserClient = await clientFactory.CreateFromLogin( + await using var controllerUserClient = await restClientFactory.CreateFromLogin( controllerAddress, newUser.Name, "asdfasdfasdfasdf"); - await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); + await using var node1TokenCopiedClient = restClientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); await node1TokenCopiedClient.Administration.Read(false, cancellationToken); // check instance info is not shared - var controllerInstance = await controllerClient.Instances.CreateOrAttach( + var controllerInstance = await controllerClient.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "ControllerInstance", @@ -682,27 +665,27 @@ await Task.WhenAny( }, cancellationToken); - var node2Instance = await node2Client.Instances.CreateOrAttach( + var node2Instance = await node2Client.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "Node2Instance", Path = Path.Combine(node2.Directory, "Node2Instance") }, cancellationToken); - var node2InstanceList = await node2Client.Instances.List(null, cancellationToken); + var node2InstanceList = await node2Client.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, node2InstanceList.Count); Assert.AreEqual(node2Instance.Id, node2InstanceList[0].Id); - Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken)); - var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken); + Assert.IsNotNull(await node2Client.RestClient.Instances.GetId(node2Instance, cancellationToken)); + var controllerInstanceList = await controllerClient.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, controllerInstanceList.Count); Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id); - Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken)); + Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken)); - await ApiAssert.ThrowsException(() => controllerClient.Instances.GetId(node2Instance, cancellationToken), ErrorCode.ResourceNotPresent); - await ApiAssert.ThrowsException(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), ErrorCode.ResourceNotPresent); + 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); // test update - await node1Client.Administration.Update( + await node1Client.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -743,13 +726,13 @@ void CheckServerUpdated(LiveTestingServer server) await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken); await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException(() => controllerClient2.Administration.Update( + await ApiAssert.ThrowsException(() => controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion }, null, - cancellationToken), ErrorCode.SwarmIntegrityCheckFailed); + cancellationToken), Api.Models.ErrorCode.SwarmIntegrityCheckFailed); // regression: test updating also works from the controller serverTask = Task.WhenAll( @@ -764,7 +747,7 @@ async Task WaitForSwarmServerUpdate2() do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node2Client2.ServerInformation(cancellationToken); + serverInformation = await node2Client2.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -773,8 +756,8 @@ await Task.WhenAny( WaitForSwarmServerUpdate2(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info2 = await node2Client2.ServerInformation(cancellationToken); - var node1Info2 = await node1Client2.ServerInformation(cancellationToken); + var node2Info2 = await node2Client2.RestClient.ServerInformation(cancellationToken); + var node1Info2 = await node1Client2.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info2); CheckInfo(node2Info2); @@ -791,7 +774,7 @@ await Task.WhenAny( gitHubToken); var downloadStream = await download.GetResult(cancellationToken); - var responseModel = await controllerClient2.Administration.Update( + var responseModel = await controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -874,7 +857,7 @@ public async Task TestSwarmReconnection() await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount) { @@ -915,11 +898,11 @@ static void CheckInfo(ServerInformationResponse serverInformation) // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node1Client, 1), + WaitForSwarmServerUpdate(node1Client.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); @@ -931,21 +914,21 @@ await Task.WhenAny( Assert.IsTrue(node1Task.IsCompleted); // it should unregister - controllerInfo = await controllerClient.ServerInformation(cancellationToken); + controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1")); // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client, 3), + WaitForSwarmServerUpdate(node2Client.RestClient, 3), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client.ServerInformation(cancellationToken); + node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); // restart the controller - await controllerClient.Administration.Restart(cancellationToken); + await controllerClient.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( controllerTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -956,10 +939,10 @@ await Task.WhenAny( // node 2 should reconnect once it's health check triggers await Task.WhenAny( - WaitForSwarmServerUpdate(controllerClient2, 1), + WaitForSwarmServerUpdate(controllerClient2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)); - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); @@ -967,36 +950,36 @@ await Task.WhenAny( await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); // restart node2 - await node2Client.Administration.Restart(cancellationToken); + await node2Client.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( node2Task, Task.Delay(TimeSpan.FromMinutes(1))); Assert.IsTrue(node1Task.IsCompleted); // should have unregistered - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(1, controllerInfo.SwarmServers.Count); Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); // update should fail await ApiAssert.ThrowsException( - () => controllerClient2.Administration.Update(new ServerUpdateRequest + () => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest { NewVersion = TestUpdateVersion }, null, cancellationToken), - ErrorCode.SwarmIntegrityCheckFailed); + Api.Models.ErrorCode.SwarmIntegrityCheckFailed); node2Task = node2.Run(cancellationToken).AsTask(); await using var node2Client2 = await CreateAdminClient(node2.ApiUrl, cancellationToken); // should re-register await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client2, 1), + WaitForSwarmServerUpdate(node2Client2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client2.ServerInformation(cancellationToken); + node2Info = await node2Client2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller")); } @@ -1047,10 +1030,10 @@ async ValueTask TestTgstation(bool interactive) try { await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - - var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory); + var restAdminClient = adminClient.RestClient; + var instanceManagerTest = new InstanceManagerTest(restAdminClient, server.Directory); var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest @@ -1073,7 +1056,7 @@ async ValueTask TestTgstation(bool interactive) if (!String.IsNullOrWhiteSpace(localRepoPath)) { await ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) @@ -1141,7 +1124,7 @@ async ValueTask RunGitCommand(string args) cancellationToken); var scriptsCopyTask = ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) @@ -1353,62 +1336,81 @@ async Task TestTgsInternal(bool openDreamOnly, CancellationToken hardCancellatio var serverTask = server.Run(cancellationToken).AsTask(); var fileDownloader = ((Host.Server)server.RealServer).Host.Services.GetRequiredService(); + var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); try { Api.Models.Instance instance; long initialStaged, initialActive, initialSessionId; - await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token)) + await using var firstAdminMultiClient = await CreateAdminClient(server.ApiUrl, cancellationToken); + + var firstAdminRestClient = firstAdminMultiClient.RestClient; + + await using (var tokenOnlyGraphQLClient = graphQLClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token.Bearer)) + { + // just testing auth works the same here + var result = await tokenOnlyGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsSuccessResult()); + } + + await using (var tokenOnlyRestClient = restClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token)) { // regression test for password change issue - var currentUser = await tokenOnlyClient.Users.Read(cancellationToken); - var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest + var currentUser = await tokenOnlyRestClient.Users.Read(cancellationToken); + var updatedUser = await tokenOnlyRestClient.Users.Update(new UserUpdateRequest { Id = currentUser.Id, Password = DefaultCredentials.DefaultAdminUserPassword, }, cancellationToken); - await ApiAssert.ThrowsException(() => tokenOnlyClient.Users.Read(cancellationToken), null); + await ApiAssert.ThrowsException(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); } // basic graphql test, to be used everywhere eventually - await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl)) + await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl)) { + // check auth works as expected + var result = await unauthenticatedGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsErrorResult()); + // test getting server info - var multiClient = new MultiServerClient(firstAdminClient, graphQLClient); + var unAuthedMultiClient = new MultiServerClient(firstAdminRestClient, unauthenticatedGraphQLClient); - await multiClient.ExecuteReadOnlyConfirmEquivalence( + await unAuthedMultiClient.ExecuteReadOnlyConfirmEquivalence( restClient => restClient.ServerInformation(cancellationToken), - async gqlClient => (await gqlClient.ServerInformationQuery.ExecuteAsync(cancellationToken)).Data, - (restServerInfo, gqlServerInfo) => restServerInfo.UpdateInProgress == gqlServerInfo.Swarm.Metadata.UpdateInProgress - && restServerInfo.Version == gqlServerInfo.Swarm.Metadata.Version - && restServerInfo.DMApiVersion == gqlServerInfo.Swarm.Metadata.DmApiVersion - && restServerInfo.InstanceLimit == gqlServerInfo.Swarm.LocalServer.Information.InstanceLimit - && restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.LocalServer.Information.UserGroupLimit - && restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.LocalServer.Information.ValidInstancePaths) - && restServerInfo.UserLimit == gqlServerInfo.Swarm.LocalServer.Information.UserLimit - && restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.LocalServer.Information.MinimumPasswordLength - && (restServerInfo.SwarmServers == gqlServerInfo.Swarm.Servers - || restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Servers.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation - { - Address = x.Address, - PublicAddress = x.PublicAddress, - Controller = x.Controller, - Identifier = x.Identifier, - })))) - && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos - || restServerInfo.OAuthProviderInfos.All(kvp => - { - var info = gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key); - return info != null - && info.Value.ServerUrl == kvp.Value.ServerUrl - && info.Value.ClientId == kvp.Value.ClientId - && info.Value.RedirectUri == kvp.Value.RedirectUri; - }))); + gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken), + (restServerInfo, gqlServerInfo) => + { + var result = restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion + && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos + || restServerInfo.OAuthProviderInfos.All(kvp => + { + var info = gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key); + return info != null + && info.Value.ServerUrl == kvp.Value.ServerUrl + && info.Value.ClientId == kvp.Value.ClientId + && info.Value.RedirectUri == kvp.Value.RedirectUri; + })); + + return result; + }, + cancellationToken); + + var testObserver = new HoldLastObserver>(); + using var subscription = await unauthenticatedGraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + testObserver, + cancellationToken); + + await Task.Delay(1000, cancellationToken); + + Assert.AreEqual(0U, testObserver.ErrorCount); + Assert.AreEqual(1U, testObserver.ResultCount); + Assert.IsTrue(testObserver.LastValue.IsAuthenticationError()); + Assert.IsTrue(testObserver.Completed); } - async ValueTask CreateUserWithNoInstancePerms() + async ValueTask CreateUserWithNoInstancePerms() { var createRequest = new UserCreateRequest() { @@ -1421,13 +1423,15 @@ async ValueTask CreateUserWithNoInstancePerms() } }; - var user = await firstAdminClient.Users.Create(createRequest, cancellationToken); + var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken); Assert.IsTrue(user.Enabled); - return await clientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken); } - var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms()); + var restartObserver = new HoldLastObserver>(); + IDisposable restartSubscription; + var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) @@ -1462,12 +1466,17 @@ async Task FailFast(Task task) InstanceResponse odInstance, compatInstance; if (!openDreamOnly) { + // force a session refresh if necessary + await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( + gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken), + cancellationToken); + jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task - var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken)); + var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(firstAdminMultiClient).Run(cancellationToken).AsTask()); - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken); @@ -1477,7 +1486,7 @@ async Task FailFast(Task task) var byondApiCompatInstance = await byondApiCompatInstanceTask; var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken)); Assert.IsTrue(Directory.Exists(instance.Path)); - instanceClient = firstAdminClient.Instances.CreateClient(instance); + instanceClient = firstAdminRestClient.Instances.CreateClient(instance); Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); nonInstanceTests = Task.WhenAll(instancesTest, adminTest, rootTest, usersTest); @@ -1488,13 +1497,13 @@ async Task FailFast(Task task) nonInstanceTests = Task.CompletedTask; jobsHubTestTask = null; instance = null; - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); odInstance = await odInstanceTask; } var instanceTest = new InstanceTest( - firstAdminClient.Instances, + firstAdminRestClient.Instances, fileDownloader, GetInstanceManager(), (ushort)server.ApiUrl.Port); @@ -1517,13 +1526,13 @@ async Task ODCompatTests() server.OpenDreamUrl, cancellationToken).AsTask()); - Assert.AreEqual(ErrorCode.OpenDreamTooOld, ex.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OpenDreamTooOld, ex.ErrorCode); await instanceTest .RunCompatTests( await edgeODVersionTask, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(odInstance), + firstAdminRestClient.Instances.CreateClient(odInstance), odDMPort.Value, odDDPort.Value, server.HighPriorityDreamDaemon, @@ -1550,7 +1559,7 @@ await instanceTest : new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451 }, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(compatInstance), + firstAdminRestClient.Instances.CreateClient(compatInstance), compatDMPort.Value, compatDDPort.Value, server.HighPriorityDreamDaemon, @@ -1590,12 +1599,45 @@ await FailFast( initialStaged = dd.StagedCompileJob.Id.Value; initialSessionId = dd.SessionId.Value; - jobsHubTest.ExpectShutdown(); - await firstAdminClient.Administration.Restart(cancellationToken); + // force a session refresh if necessary + await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( + gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken), + cancellationToken); + + restartSubscription = await firstAdminMultiClient.GraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + restartObserver, + cancellationToken); + + try + { + await Task.Delay(1000, cancellationToken); + + jobsHubTest.ExpectShutdown(); + await firstAdminRestClient.Administration.Restart(cancellationToken); + } + catch + { + restartSubscription.Dispose(); + throw; + } } - await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); - Assert.IsTrue(serverTask.IsCompleted); + try + { + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); + Assert.IsTrue(serverTask.IsCompleted); + + Assert.AreEqual(0U, restartObserver.ErrorCount); + Assert.AreEqual(1U, restartObserver.ResultCount); + restartObserver.LastValue.EnsureNoErrors(); + Assert.IsTrue(restartObserver.Completed); + Assert.AreEqual(SessionInvalidationReason.ServerShutdown, restartObserver.LastValue.Data.SessionInvalidated); + } + finally + { + restartSubscription.Dispose(); + } // test the reattach message queueing // for the code coverage really... @@ -1640,8 +1682,10 @@ await FailFast( // chat bot start and DD reattach test serverTask = server.Run(cancellationToken).AsTask(); - await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) + await using (var multiClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var adminClient = multiClient.RestClient; + await jobsHubTest.WaitForReconnect(cancellationToken); var instanceClient = adminClient.Instances.CreateClient(instance); @@ -1745,8 +1789,9 @@ async Task WaitForInitialJobs(IInstanceClient instanceClient) var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var dd = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1778,7 +1823,7 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest expectedStaged = compileJob.Id.Value; jobsHubTest.ExpectShutdown(); - await adminClient.Administration.Restart(cancellationToken); + await restAdminClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1788,8 +1833,9 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest serverTask = server.Run(cancellationToken).AsTask(); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1804,7 +1850,7 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest await using var repoTestObj = new RepositoryTest(instanceClient.Repository, instanceClient.Jobs); var repoTest = repoTestObj.RunPostTest(cancellationToken); - await using var chatTestObj = new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance); + await using var chatTestObj = new ChatTest(instanceClient.ChatBots, restAdminClient.Instances, instanceClient.Jobs, instance); await chatTestObj.RunPostTest(cancellationToken); await repoTest; @@ -1813,7 +1859,7 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest jobsHubTest.CompleteNow(); await jobsHubTestTask; - await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken); + await new InstanceManagerTest(restAdminClient, server.Directory).RunPostTest(instance, cancellationToken); } } catch (ApiException ex) @@ -1848,32 +1894,84 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest await serverTask; } - async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + ValueTask CreateAdminClient(Uri url, CancellationToken cancellationToken) + => CreateClient(url, DefaultCredentials.AdminUserName, DefaultCredentials.DefaultAdminUserPassword, true, cancellationToken); + + async ValueTask CreateClient( + Uri url, + string username, + string password, + bool retry, + CancellationToken cancellationToken = default) { url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty)); var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2); for (var I = 1; ; ++I) { + ValueTask restClientTask; + ValueTask graphQLClientTask; try { - System.Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); - return await clientFactory.CreateFromLogin( + Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); + + restClientTask = restClientFactory.CreateFromLogin( url, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword, + username, + password, cancellationToken: cancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + graphQLClientTask = graphQLClientFactory.CreateFromLogin( + url, + username, + password, + cancellationToken: cts.Token); + + IRestServerClient restClient; + try + { + restClient = await restClientTask; + } + catch (Exception restException) when (restException is not HttpRequestException && restException is not ServiceUnavailableException) + { + cts.Cancel(); + try + { + await (await graphQLClientTask).DisposeAsync(); + } + catch (OperationCanceledException) + { + } + catch (Exception graphQLException) + { + throw new AggregateException(restException, graphQLException); + } + + throw; + } + + try + { + return new MultiServerClient( + restClient, + await graphQLClientTask); + } + catch + { + await restClient.DisposeAsync(); + throw; + } } catch (HttpRequestException) { //migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt || !retry) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } catch (ServiceUnavailableException) { // migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt || !retry) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index ceebac43560..b5a821a9951 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -1,5 +1,11 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Elastic.CommonSchema; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -10,238 +16,563 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; +using Tgstation.Server.Client.GraphQL; using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.System; namespace Tgstation.Server.Tests.Live { sealed class UsersTest { - readonly IRestServerClient serverClient; + readonly IMultiServerClient serverClient; - public UsersTest(IRestServerClient serverClient) + public UsersTest(IMultiServerClient serverClient) { this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient)); } - public async Task Run(CancellationToken cancellationToken) + public async ValueTask Run(CancellationToken cancellationToken) { - await Task.WhenAll( - BasicTests(cancellationToken), + var observer = new HoldLastObserver>(); + using var subscription = await serverClient.Subscribe( + gql => gql.SubscribeUsers.Watch(), + observer, + cancellationToken); + + await BasicTests(cancellationToken); + + await ValueTaskExtensions.WhenAll( TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)); await TestPagination(cancellationToken); + + Assert.IsFalse(observer.Completed); + Assert.AreEqual(0U, observer.ErrorCount); + Assert.AreEqual(new PlatformIdentifier().IsWindows ? 108U : 107U, observer.ResultCount); // sys user + observer.LastValue.EnsureNoErrors(); } - async Task BasicTests(CancellationToken cancellationToken) + async ValueTask BasicTests(CancellationToken cancellationToken) { - var user = await serverClient.Users.Read(cancellationToken); - Assert.IsNotNull(user); - Assert.AreEqual("Admin", user.Name); - Assert.IsNull(user.SystemIdentifier); - Assert.AreEqual(true, user.Enabled); - Assert.IsNotNull(user.OAuthConnections); - Assert.IsNotNull(user.PermissionSet); - Assert.IsNotNull(user.PermissionSet.Id); - Assert.IsNotNull(user.PermissionSet.InstanceManagerRights); - Assert.IsNotNull(user.PermissionSet.AdministrationRights); - - var systemUser = user.CreatedBy; - Assert.IsNotNull(systemUser); - Assert.AreEqual("TGS", systemUser.Name); - - var users = await serverClient.Users.List(null, cancellationToken); - Assert.IsTrue(users.Count > 0); - Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); - - await ApiAssert.ThrowsException(() => serverClient.Users.GetId(systemUser, cancellationToken)); - await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdateRequest - { - Id = systemUser.Id - }, cancellationToken)); - - var sampleOAuthConnections = new List - { - new OAuthConnection - { - ExternalUserId = "asdfasdf", - Provider = OAuthProvider.Discord - } - }; - await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdateRequest - { - Id = user.Id, - OAuthConnections = sampleOAuthConnections - }, cancellationToken), ErrorCode.AdminUserCannotOAuth); - - var testUser = await serverClient.Users.Create( - new UserCreateRequest + var (restUser, gqlUser) = await serverClient.ExecuteReadOnlyConfirmEquivalence( + client => client.Users.Read(cancellationToken), + client => client.ReadCurrentUser.ExecuteAsync(cancellationToken), + (restResult, graphQLResult) => { - Name = $"BasicTestUser", - Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + 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.SystemIdentifier == gqlUser.SystemIdentifier + && restResult.CreatedBy.Name == gqlUser.CreatedBy.Name; }, cancellationToken); - Assert.IsNotNull(testUser.OAuthConnections); - testUser = await serverClient.Users.Update( - new UserUpdateRequest - { - Id = testUser.Id, - OAuthConnections = sampleOAuthConnections - }, - cancellationToken); - - Assert.AreEqual(1, testUser.OAuthConnections.Count); - Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections.First().ExternalUserId); - Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections.First().Provider); - + Assert.IsNotNull(restUser); + Assert.AreEqual("Admin", restUser.Name); + Assert.IsNull(restUser.SystemIdentifier); + Assert.AreEqual(true, restUser.Enabled); + Assert.IsNotNull(restUser.OAuthConnections); + Assert.IsNotNull(restUser.PermissionSet); + Assert.IsNotNull(restUser.PermissionSet.Id); + Assert.IsNotNull(restUser.PermissionSet.InstanceManagerRights); + Assert.IsNotNull(restUser.PermissionSet.AdministrationRights); + + var systemUser = restUser.CreatedBy; + Assert.IsNotNull(systemUser); + Assert.AreEqual("TGS", systemUser.Name); - var group = await serverClient.Groups.Create( - new UserGroupCreateRequest + await serverClient.Execute( + async client => { - Name = "TestGroup" + var users = await client.Users.List(null, cancellationToken); + 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 + { + Id = systemUser.Id + }, cancellationToken)); + + var sampleOAuthConnections = new List + { + new() + { + ExternalUserId = "asdfasdf", + Provider = Api.Models.OAuthProvider.Discord + } + }; + + await ApiAssert.ThrowsException(() => client.Users.Update(new UserUpdateRequest + { + Id = restUser.Id, + OAuthConnections = sampleOAuthConnections + }, cancellationToken), Api.Models.ErrorCode.AdminUserCannotOAuth); + + var testUser = await client.Users.Create( + new UserCreateRequest + { + Name = $"BasicTestUser", + Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + }, + cancellationToken); + + Assert.IsNotNull(testUser.OAuthConnections); + testUser = await client.Users.Update( + new UserUpdateRequest + { + Id = testUser.Id, + OAuthConnections = sampleOAuthConnections + }, + cancellationToken); + + Assert.AreEqual(1, testUser.OAuthConnections.Count); + Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections.First().ExternalUserId); + Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections.First().Provider); + + var group = await client.Groups.Create( + new UserGroupCreateRequest + { + Name = "TestGroup" + }, + cancellationToken); + Assert.AreEqual(group.Name, "TestGroup"); + Assert.IsNotNull(group.PermissionSet); + Assert.IsNotNull(group.PermissionSet.Id); + Assert.AreEqual(AdministrationRights.None, group.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.None, group.PermissionSet.InstanceManagerRights); + + var group2 = await client.Groups.Create(new UserGroupCreateRequest + { + Name = "TestGroup2", + PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.List + } + }, cancellationToken); + Assert.AreEqual(AdministrationRights.None, group2.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.List, group2.PermissionSet.InstanceManagerRights); + + var groups = await client.Groups.List(null, cancellationToken); + Assert.AreEqual(2, groups.Count); + + foreach (var igroup in groups) + { + Assert.IsNotNull(igroup.Users); + Assert.IsNotNull(igroup.PermissionSet); + } + + await client.Groups.Delete(group2, cancellationToken); + + groups = await client.Groups.List(null, cancellationToken); + Assert.AreEqual(1, groups.Count); + + group = await client.Groups.Update(new UserGroupUpdateRequest + { + Id = groups[0].Id, + PermissionSet = new PermissionSet + { + InstanceManagerRights = RightsHelper.AllRights(), + AdministrationRights = RightsHelper.AllRights(), + } + }, cancellationToken); + + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.AdministrationRights); + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.InstanceManagerRights); + + UserUpdateRequest testUserUpdate = new UserCreateRequest + { + Name = "TestUserWithNoPassword", + Password = string.Empty + }; + + await ApiAssert.ThrowsException(() => client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), Api.Models.ErrorCode.UserPasswordLength); + + testUserUpdate.OAuthConnections = + [ + new() + { + ExternalUserId = "asdf", + Provider = Api.Models.OAuthProvider.GitHub + } + ]; + + var testUser2 = await client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken); + + testUserUpdate = new UserUpdateRequest + { + Id = testUser2.Id, + PermissionSet = testUser2.PermissionSet, + Group = new Api.Models.Internal.UserGroup + { + Id = group.Id + }, + }; + await ApiAssert.ThrowsException( + () => client.Users.Update( + testUserUpdate, + cancellationToken), + Api.Models.ErrorCode.UserGroupAndPermissionSet); + + testUserUpdate.PermissionSet = null; + + testUser2 = await client.Users.Update(testUserUpdate, cancellationToken); + + Assert.IsNull(testUser2.PermissionSet); + Assert.IsNotNull(testUser2.Group); + Assert.AreEqual(group.Id, testUser2.Group.Id); + + var group4 = await client.Groups.GetId(group, cancellationToken); + Assert.IsNotNull(group4.Users); + Assert.AreEqual(1, group4.Users.Count); + Assert.AreEqual(testUser2.Id, group4.Users.First().Id); + Assert.IsNotNull(group4.PermissionSet); + + testUserUpdate.Group = null; + testUserUpdate.PermissionSet = new PermissionSet + { + AdministrationRights = RightsHelper.AllRights(), + InstanceManagerRights = RightsHelper.AllRights(), + }; + + testUser2 = await client.Users.Update(testUserUpdate, cancellationToken); + Assert.IsNull(testUser2.Group); + Assert.IsNotNull(testUser2.PermissionSet); }, - cancellationToken); - Assert.AreEqual(group.Name, "TestGroup"); - Assert.IsNotNull(group.PermissionSet); - Assert.IsNotNull(group.PermissionSet.Id); - Assert.AreEqual(AdministrationRights.None, group.PermissionSet.AdministrationRights); - Assert.AreEqual(InstanceManagerRights.None, group.PermissionSet.InstanceManagerRights); - - var group2 = await serverClient.Groups.Create(new UserGroupCreateRequest - { - Name = "TestGroup2", - PermissionSet = new PermissionSet + async client => { - InstanceManagerRights = InstanceManagerRights.List - } - }, cancellationToken); - Assert.AreEqual(AdministrationRights.None, group2.PermissionSet.AdministrationRights); - Assert.AreEqual(InstanceManagerRights.List, group2.PermissionSet.InstanceManagerRights); - - var groups = await serverClient.Groups.List(null, cancellationToken); - Assert.AreEqual(2, groups.Count); - - foreach (var igroup in groups) - { - Assert.IsNotNull(igroup.Users); - Assert.IsNotNull(igroup.PermissionSet); - } - - await serverClient.Groups.Delete(group2, cancellationToken); - - groups = await serverClient.Groups.List(null, cancellationToken); - Assert.AreEqual(1, groups.Count); - - group = await serverClient.Groups.Update(new UserGroupUpdateRequest - { - Id = groups[0].Id, - PermissionSet = new PermissionSet - { - InstanceManagerRights = RightsHelper.AllRights(), - AdministrationRights = RightsHelper.AllRights(), - } - }, cancellationToken); - - Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.AdministrationRights); - Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.InstanceManagerRights); - - UserUpdateRequest testUserUpdate = new UserCreateRequest - { - Name = "TestUserWithNoPassword", - Password = string.Empty - }; - - await ApiAssert.ThrowsException(() => serverClient.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), ErrorCode.UserPasswordLength); - - testUserUpdate.OAuthConnections = new List - { - new OAuthConnection - { - ExternalUserId = "asdf", - Provider = OAuthProvider.GitHub - } - }; - - var testUser2 = await serverClient.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken); - - testUserUpdate = new UserUpdateRequest - { - Id = testUser2.Id, - PermissionSet = testUser2.PermissionSet, - Group = new Api.Models.Internal.UserGroup - { - Id = group.Id - }, - }; - await ApiAssert.ThrowsException( - () => serverClient.Users.Update( - testUserUpdate, - cancellationToken), - ErrorCode.UserGroupAndPermissionSet); - - testUserUpdate.PermissionSet = null; - - testUser2 = await serverClient.Users.Update(testUserUpdate, cancellationToken); - - Assert.IsNull(testUser2.PermissionSet); - Assert.IsNotNull(testUser2.Group); - Assert.AreEqual(group.Id, testUser2.Group.Id); - - group = await serverClient.Groups.GetId(group, cancellationToken); - Assert.IsNotNull(group.Users); - Assert.AreEqual(1, group.Users.Count); - Assert.AreEqual(testUser2.Id, group.Users.First().Id); - Assert.IsNotNull(group.PermissionSet); - - testUserUpdate.Group = null; - testUserUpdate.PermissionSet = new PermissionSet - { - AdministrationRights = RightsHelper.AllRights(), - InstanceManagerRights = RightsHelper.AllRights(), - }; - - testUser2 = await serverClient.Users.Update(testUserUpdate, cancellationToken); - Assert.IsNull(testUser2.Group); - Assert.IsNotNull(testUser2.PermissionSet); + var result = await client.RunOperation(gql => gql.ListUsers.ExecuteAsync(cancellationToken), cancellationToken); + result.EnsureNoErrors(); + var users = result.Data.Swarm.Users.QueryableUsers; + Assert.IsTrue(users.TotalCount > 0); + Assert.AreEqual(Math.Min(ApiController.DefaultPageSize, users.TotalCount), users.Nodes.Count); + + var tgsUserResult = await client.RunOperation(gql => gql.GetUserNameByNodeId.ExecuteAsync(gqlUser.Swarm.Users.Current.CreatedBy.Id, cancellationToken), cancellationToken); + tgsUserResult.EnsureNoErrors(); + var tgsUserResult2 = await client.RunOperation(gql => gql.GetUserById.ExecuteAsync(gqlUser.Swarm.Users.Current.CreatedBy.Id, cancellationToken), cancellationToken); + Assert.IsTrue(tgsUserResult2.IsErrorResult()); + + var sampleOAuthConnections = new List + { + new() + { + ExternalUserId = "asdfasdf", + Provider = Client.GraphQL.OAuthProvider.Discord, + } + }; + + await ApiAssert.OperationFails( + client, + gql => gql.SetUserOAuthConnections.ExecuteAsync( + gqlUser.Swarm.Users.Current.Id, + sampleOAuthConnections, + cancellationToken), + data => data.UpdateUser, + Client.GraphQL.ErrorCode.AdminUserCannotOAuth, + cancellationToken); + + var testUserResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserWithPasswordSelectOAuthConnections.ExecuteAsync("BasicTestUser", "asdfasdjfhauwiehruiy273894234jhndjkwh", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + cancellationToken); + + var testUserResult2 = await client.RunMutationEnsureNoErrors( + gql => gql.UpdateUserOAuthConnections.ExecuteAsync( + testUserResult.User.Id, + sampleOAuthConnections, + cancellationToken), + data => data.UpdateUser, + cancellationToken); + + var testUser = testUserResult2.User; + Assert.IsNotNull(testUser.OAuthConnections); + Assert.AreEqual(1, testUser.OAuthConnections.Count); + Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections[0].ExternalUserId); + Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections[0].Provider); + + var groupResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserGroup.ExecuteAsync("TestGroup", cancellationToken), + data => data.CreateUserGroup, + cancellationToken); + + var group = groupResult.UserGroup; + Assert.AreEqual(group.Name, "TestGroup"); + Assert.IsNotNull(group.PermissionSet); + + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanList); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetConfiguration); + + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanWriteUsers); + + var group2Result = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserGroupWithInstanceListPerm.ExecuteAsync("TestGroup2", cancellationToken), + data => data.CreateUserGroup, + cancellationToken); + + var group2 = group2Result.UserGroup; + + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsTrue(group2.PermissionSet.InstanceManagerRights.CanList); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetConfiguration); + + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanWriteUsers); + + var groupsResult = await client.RunQueryEnsureNoErrors( + gql => gql.ListUserGroups.ExecuteAsync(cancellationToken), + cancellationToken); + + var groups = groupsResult.Swarm.Users.Groups.QueryableGroups; + Assert.AreEqual(2, groups.TotalCount); + + foreach (var igroup in groups.Nodes) + Assert.IsNotNull(igroup.Id); + + var deleteResult = await client.RunMutationEnsureNoErrors( + gql => gql.DeleteUserGroup.ExecuteAsync(group2.Id, cancellationToken), + data => data.DeleteEmptyUserGroup, + cancellationToken); + + groupsResult = await client.RunQueryEnsureNoErrors( + gql => gql.ListUserGroups.ExecuteAsync(cancellationToken), + cancellationToken); + + groups = groupsResult.Swarm.Users.Groups.QueryableGroups; + Assert.AreEqual(1, groups.TotalCount); + + foreach (var igroup in groups.Nodes) + Assert.IsNotNull(igroup.Id); + + var group3Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetFullPermsOnUserGroup.ExecuteAsync(group.Id, cancellationToken), + data => data.UpdateUserGroup, + cancellationToken); + + var group3 = group3Result.UserGroup; + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanList); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetConfiguration); + + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanWriteUsers); + + await ApiAssert.OperationFails( + client, + gql => gql.CreateUserWithPassword.ExecuteAsync("TestUserWithNoPassword", String.Empty, cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + Client.GraphQL.ErrorCode.ModelValidationFailure, + cancellationToken); + + await ApiAssert.OperationFails( + client, + gql => gql.CreateUserWithPassword.ExecuteAsync("TestUserWithShortPassword", "a", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + Client.GraphQL.ErrorCode.UserPasswordLength, + cancellationToken); + + var oAuthCreateResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserFromOAuthConnection.ExecuteAsync( + "TestUserWithNoPassword", + [ + new() + { + ExternalUserId = "asdf", + Provider = Client.GraphQL.OAuthProvider.GitHub, + } + ], + cancellationToken), + data => data.CreateUserByOAuthAndPermissionSet, + cancellationToken); + + var testUser2 = oAuthCreateResult.User; + + var testUser22Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetUserGroup.ExecuteAsync(testUser2.Id, group.Id, cancellationToken), + data => data.UpdateUserSetGroup, + cancellationToken); + + var testUser22 = testUser22Result.User; + + Assert.IsNull(testUser22.OwnedPermissionSet); + Assert.IsNotNull(testUser22.Group); + Assert.AreEqual(group.Id, testUser22.Group.Id); + + var group4Result = await client.RunQueryEnsureNoErrors( + gql => gql.GetSomeGroupInfo.ExecuteAsync(group.Id, cancellationToken), + cancellationToken); + var group4 = group4Result.Swarm.Users.Groups.ById; + + Assert.IsNotNull(group4.QueryableUsersByGroup.Nodes); + Assert.AreEqual(1, group4.QueryableUsersByGroup.TotalCount); + Assert.AreEqual(testUser2.Id, group4.QueryableUsersByGroup.Nodes[0].Id); + Assert.IsNotNull(group4.PermissionSet); + + var testUser4Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetUserPermissionSet.ExecuteAsync( + testUser2.Id, + new PermissionSetInput + { + AdministrationRights = new AdministrationRightsFlagsInput + { + CanChangeVersion = true, + CanDownloadLogs = true, + CanEditOwnOAuthConnections = true, + CanEditOwnPassword = true, + CanReadUsers = true, + CanRestartHost = true, + CanUploadVersion = true, + CanWriteUsers = true, + }, + InstanceManagerRights = new InstanceManagerRightsFlagsInput + { + CanCreate = true, + CanDelete = true, + CanGrantPermissions = true, + CanList = true, + CanRead = true, + CanRelocate = true, + CanRename = true, + CanSetAutoUpdate = true, + CanSetChatBotLimit = true, + CanSetConfiguration = true, + CanSetOnline = true, + } + }, + cancellationToken), + data => data.UpdateUserSetOwnedPermissionSet, + cancellationToken); + + var testUser4 = testUser4Result.User; + Assert.IsNull(testUser4.Group); + Assert.IsNotNull(testUser4.OwnedPermissionSet); + }); } - async Task TestCreateSysUser(CancellationToken cancellationToken) + ValueTask TestCreateSysUser(CancellationToken cancellationToken) { var sysId = Environment.UserName; - var update = new UserCreateRequest - { - SystemIdentifier = sysId - }; - if (new PlatformIdentifier().IsWindows) - await serverClient.Users.Create(update, cancellationToken); - else - await ApiAssert.ThrowsException(() => serverClient.Users.Create(update, cancellationToken), ErrorCode.RequiresPosixSystemIdentity); + + return serverClient.Execute( + async restClient => + { + var update = new UserCreateRequest + { + SystemIdentifier = sysId + }; + if (new PlatformIdentifier().IsWindows) + await restClient.Users.Create(update, cancellationToken); + else + await ApiAssert.ThrowsException(() => restClient.Users.Create(update, cancellationToken), Api.Models.ErrorCode.RequiresPosixSystemIdentity); + }, + async graphQLClient => + { + if (new PlatformIdentifier().IsWindows) + { + await graphQLClient.RunMutationEnsureNoErrors( + gql => gql.CreateSystemUserWithPermissionSet.ExecuteAsync(sysId, cancellationToken), + data => data.CreateUserBySystemIDAndPermissionSet, + cancellationToken); + } + else + await ApiAssert.OperationFails( + graphQLClient, + gql => gql.CreateSystemUserWithPermissionSet.ExecuteAsync(sysId, cancellationToken), + data => data.CreateUserBySystemIDAndPermissionSet, + Client.GraphQL.ErrorCode.RequiresPosixSystemIdentity, + cancellationToken); + }); } - async Task TestSpamCreation(CancellationToken cancellationToken) + async ValueTask TestSpamCreation(CancellationToken cancellationToken) { // Careful with this, very easy to overload the thread pool const int RepeatCount = 100; - var tasks = new List>(RepeatCount); + var tasks = new List(RepeatCount); ThreadPool.GetMaxThreads(out var defaultMaxWorker, out var defaultMaxCompletion); ThreadPool.GetMinThreads(out var defaultMinWorker, out var defaultMinCompletion); + ConcurrentBag ids = new ConcurrentBag(); try { ThreadPool.SetMinThreads(Math.Min(RepeatCount * 4, defaultMaxWorker), Math.Min(RepeatCount * 4, defaultMaxCompletion)); for (int i = 0; i < RepeatCount; ++i) { - var task = - serverClient.Users.Create( - new UserCreateRequest + var iLocal = i; + ValueTask CreateSpamUser() + => serverClient.Execute( + async restClient => { - Name = $"SpamTestUser_{i}", - Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + var user = await restClient.Users.Create( + new UserCreateRequest + { + Name = $"SpamTestUser_{iLocal}", + Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + }, + cancellationToken); + + ids.Add(user.Id); }, - cancellationToken); - tasks.Add(task); + async graphQLClient => + { + var result = await graphQLClient.RunMutationEnsureNoErrors( + gql => gql.CreateUserWithPassword.ExecuteAsync($"SpamTestUser_{iLocal}", "asdfasdjfhauwiehruiy273894234jhndjkwh", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + cancellationToken); + + ids.Add(result.User.Id); + }); + + tasks.Add(CreateSpamUser()); } await ValueTaskExtensions.WhenAll(tasks); @@ -251,57 +582,127 @@ async Task TestSpamCreation(CancellationToken cancellationToken) ThreadPool.SetMinThreads(defaultMinWorker, defaultMinCompletion); } - Assert.AreEqual(RepeatCount, tasks.Select(task => task.Result.Id).Distinct().Count(), "Did not receive expected number of unique user IDs!"); + Assert.AreEqual(RepeatCount, ids.Distinct().Count(), "Did not receive expected number of unique user IDs!"); } - async Task TestPagination(CancellationToken cancellationToken) + ValueTask TestPagination(CancellationToken cancellationToken) { - // we test pagination here b/c it's the only spot we have a decent amount of entities - var nullSettings = await serverClient.Users.List(null, cancellationToken); - var emptySettings = await serverClient.Users.List( - new PaginationSettings - { - }, cancellationToken); - - Assert.AreEqual(nullSettings.Count, emptySettings.Count); - Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); - - await ApiAssert.ThrowsException>(() => serverClient.Users.List( - new PaginationSettings - { - PageSize = -2143 - }, cancellationToken), ErrorCode.ApiInvalidPageOrPageSize); - await ApiAssert.ThrowsException>(() => serverClient.Users.List( - new PaginationSettings + var expectedCount = new PlatformIdentifier().IsWindows ? 106 : 105; // system user + return serverClient.Execute( + async restClient => { - PageSize = int.MaxValue - }, cancellationToken), ErrorCode.ApiPageTooLarge); - - await serverClient.Users.List( - new PaginationSettings - { - PageSize = 50 + // we test pagination here b/c it's the only spot we have a decent amount of entities + var nullSettings = await restClient.Users.List(null, cancellationToken); + var emptySettings = await restClient.Users.List( + new PaginationSettings + { + }, cancellationToken); + + Assert.AreEqual(nullSettings.Count, emptySettings.Count); + Assert.AreEqual(expectedCount, nullSettings.Count); + Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); + + await ApiAssert.ThrowsException>(() => restClient.Users.List( + new PaginationSettings + { + PageSize = -2143 + }, cancellationToken), Api.Models.ErrorCode.ApiInvalidPageOrPageSize); + await ApiAssert.ThrowsException>(() => restClient.Users.List( + new PaginationSettings + { + PageSize = ApiController.MaximumPageSize + 1, + }, cancellationToken), Api.Models.ErrorCode.ApiPageTooLarge); + + var users = await restClient.Users.List( + new PaginationSettings + { + PageSize = ApiController.MaximumPageSize, + RetrieveCount = ApiController.MaximumPageSize, + }, + cancellationToken); + + Assert.AreEqual(ApiController.MaximumPageSize, users.Count); + + var skipped = await restClient.Users.List(new PaginationSettings + { + Offset = 50, + RetrieveCount = 5 + }, cancellationToken); + Assert.AreEqual(5, skipped.Count); + + var allAfterSkipped = await restClient.Users.List(new PaginationSettings + { + Offset = 50, + }, cancellationToken); + Assert.IsTrue(5 < allAfterSkipped.Count); + + var limited = await restClient.Users.List(new PaginationSettings + { + RetrieveCount = 12, + }, cancellationToken); + Assert.AreEqual(12, limited.Count); }, - cancellationToken); - - var skipped = await serverClient.Users.List(new PaginationSettings - { - Offset = 50, - RetrieveCount = 5 - }, cancellationToken); - Assert.AreEqual(5, skipped.Count); - - var allAfterSkipped = await serverClient.Users.List(new PaginationSettings - { - Offset = 50, - }, cancellationToken); - Assert.IsTrue(5 < allAfterSkipped.Count); + graphQLClient => + { + async ValueTask TestPageSize(int? inputPageSize) + { + var outputPageSize = inputPageSize ?? ApiController.DefaultPageSize; + + string cursor = null; + + var exactMatch = (expectedCount % outputPageSize) == 0; + var expectedIterations = (expectedCount / outputPageSize) + (exactMatch ? 0 : 1); + for (int i = 0; i < expectedIterations; ++i) + { + var isLastIteration = i == expectedIterations - 1; + var queryable = (await graphQLClient.RunQueryEnsureNoErrors( + gql => gql.PageUserIds.ExecuteAsync(inputPageSize, cursor, cancellationToken), + cancellationToken)) + .Swarm + .Users + .QueryableUsers; + + if (!isLastIteration || exactMatch) + Assert.AreEqual(outputPageSize, queryable.Nodes.Count); + else + Assert.AreEqual(expectedCount % outputPageSize, queryable.Nodes.Count); + Assert.AreEqual(!isLastIteration, queryable.PageInfo.HasNextPage); + Assert.IsNotNull(queryable.PageInfo.EndCursor); + + cursor = queryable.PageInfo.EndCursor; + } + } + + async ValueTask TestBadPageSize(int size) + { + var result = await graphQLClient.RunOperation( + gql => gql.PageUserIds.ExecuteAsync(size, null, cancellationToken), + cancellationToken); - var limited = await serverClient.Users.List(new PaginationSettings - { - RetrieveCount = 12, - }, cancellationToken); - Assert.AreEqual(12, limited.Count); + if (size == 0) + { + // special case + result.EnsureNoErrors(); + Assert.AreEqual(0, result.Data.Swarm.Users.QueryableUsers.Nodes.Count); + return; + } + + var errored = result.IsErrorResult(); + Assert.IsTrue(errored); + } + + return ValueTaskExtensions.WhenAll( + TestPageSize(null), + TestPageSize(ApiController.DefaultPageSize), + TestPageSize(ApiController.DefaultPageSize / 2), + TestPageSize(ApiController.MaximumPageSize), + TestPageSize(1), + TestBadPageSize(-1), + TestBadPageSize(0), + TestBadPageSize(ApiController.MaximumPageSize + 1), + TestBadPageSize(Int32.MinValue), + TestBadPageSize(Int32.MaxValue)); + }); } } }